Rozdział 3. Funkcje

> Dodaj do ulubionych

W programach często tak jest, że tę samą czynność trzeba wykonać kilka razy w różnych miejscach. Wpisywanie wszystkich instrukcji za każdym razem, gdy są potrzebne jest żmudne i łatwo przy tym popełnić jakiś błąd. Lepiej byłoby je zebrać w jednym miejscu i zawsze, gdy ich wykonanie jest potrzebne nakazywać programowi zaglądać do tego miejsca. Do tego właśnie służą funkcje: są blokami kodu, który program może wykonać zawsze, gdy jest mu to potrzebne. Aby wydrukować napis na ekranie, trzeba napisać kilka instrukcji, ale mając funkcję print możemy po prostu napisać print("Aleph").

Jednak traktowanie funkcji tylko jako zwykłych zbiorników instrukcji jest niesprawiedliwe. W zależności od potrzeby mogą pełnić rolę czystych funkcji, algorytmów, pośrednich odwołań, abstrakcji, decyzji, modułów, kontynuacji, struktur danych itd. Każdy programista musi biegle posługiwać się funkcjami. Ten rozdział stanowi tylko wprowadzenie do funkcji. Bardziej dogłębnie temat ten jest opisany w rozdziale 6.


Funkcje czyste

O czystych funkcjach uczyłeś się na lekcjach matematyki w szkole. Na przykład obliczanie cosinusa lub wartości bezwzględnej jakiejś liczby to czyste funkcje jednego argumentu. Dodawanie to czysta funkcja dwóch argumentów.

Definicja czystej funkcji głosi, że jest to taka funkcja, która dla tych samych argumentów zawsze zwraca tę sama wartość i nie ma skutków ubocznych. Funkcje te pobierają argumenty, zwracają wartości obliczone przy użyciu tych argumentów i nie mieszają nigdzie w swoim otoczeniu.

W JavaScripcie dodawanie jest operatorem, ale można by było je zrealizować jako funkcję (i chociaż wydaje się to bezsensowne, zdarzą nam się sytuacje, w których będzie to przydatne):

function add(a, b) {
  return a + b;
}

show(add(2, 2));

Ta funkcja nazywa się add. Jej argumenty nazywają się a i b. Instrukcja return a + b; stanowi treść właściwą tej funkcji.

Do tworzenia nowych funkcji służy słowo kluczowe function. Jeśli za słowem tym znajduje się nazwa zmiennej, utworzona funkcja zostanie zapisana jako wartość tej zmiennej. Po nazwie znajduje się lista nazw argumentów, a za nią w końcu mamy właściwą treść funkcji. W odróżnieniu od instrukcji while i if, treść właściwa funkcji musi być objęta klamrami1.

Słowo kluczowe return, po którym znajduje się wyrażenie określa wartość zwrotną funkcji. Gdy zostaje wykonana instrukcja return, sterowanie jest przekazywane na zewnątrz funkcji do miejsca, w którym ta funkcja została wywołana i wartość zwrotna zostaje przekazana do kodu, który to wywołanie wykonał. Jeśli za instrukcją return nie ma żadnego wyrażenia, funkcja zwraca wartość undefined.

W treści funkcji oczywiście może znajdować się dowolna liczba instrukcji. Poniższa funkcja oblicza potęgi liczb (z dodatnimi całkowitoliczbowymi wykładnikami):

function power(base, exponent) {
  var result = 1;
  for (var count = 0; count < exponent; count++)
    result *= base;
  return result;
}

show(power(2, 10));

Jeśli rozwiązałeś ćwiczenie 2.2, to ta technika nie powinna być Ci obca.

Utworzenie zmiennej (result) i modyfikacja jej wartości to skutki uboczne. Czy nie napisałem kilka akapitów wcześniej, że czyste funkcje nie mają skutków ubocznych?

Zmienna utworzona w funkcji istnieje tylko w tej funkcji. Jest to bardzo korzystne, ponieważ gdyby było inaczej, programista musiałby dla każdej zmiennej w programie wymyślić inną nazwę. Ponieważ zmienna result istnieje tylko w funkcji power, modyfikacje jej wartości mają znaczenie tylko do momentu zwrotu przez tę funkcję wartości i z perspektywy kodu wywołującego nie ma żadnego skutku ubocznego.


Ćwiczenie 3.1

Napisz funkcję o nazwie absolute zwracającą wartość bezwzględną podanego jej argumentu. Wartość bezwzględna liczby ujemnej to jej dodatni odpowiednik, a wartość bezwzględna liczby dodatniej (i zera) to po prostu ta liczba.


Czyste funkcje mają dwie bardzo ciekawe właściwości. Łatwo się o nich rozmyśla i łatwo się ich używa.

Jeśli funkcja jest czysta, jej wywołanie samo w sobie można traktować jak rzecz. Jeśli nie jesteś pewien, czy ta funkcja działa poprawnie, możesz ją sprawdzić w konsoli, co jest łatwe, ponieważ działanie tej funkcji nie zależy od żadnego kontekstu2. Testy takie można łatwo zautomatyzować, tzn. można napisać program testujący wybraną funkcję. Funkcje nie będące czystymi mogą zwracać różne wartości zależnie od różnych czynników oraz mają skutki uboczne, które trudno przewidzieć i przetestować.

Ponieważ funkcje czyste są samowystarczalne, częściej znajdują zastosowanie i są przydatne w większej liczbie sytuacji niż funkcje nie będące czystymi. Weźmy np. taką funkcję show. Jej przydatność zależy od tego, czy na ekranie znajduje się specjalne miejsce do drukowania danych wyjściowych. Jeśli nie ma takiego miejsca, ta funkcja jest bezużyteczna. Łatwo można sobie wyobrazić podobną funkcję, niech będzie o nazwie format, która pobiera jako argument jakąś wartość i zwraca łańcuch reprezentujący tę wartość. Ta funkcja będzie przydatna w większej liczbie sytuacji niż show.

Oczywiście funkcja format rozwiązuje inny problem niż show, a poza tym żadna czysta funkcja jej nie zastąpi, ponieważ to działanie po prostu wymaga skutku ubocznego. W wielu przypadkach zwyczajnie potrzebna jest funkcja nie będąca czystą. Zdarza się też, że dany problem można rozwiązać przy użyciu czystej funkcji, ale jej nie czysty odpowiednik jest bardziej wygodny lub skuteczny.

Jeśli więc coś można w łatwy sposób wyrazić jako funkcję czystą, zrób to. Ale nie miej żadnych oporów przed używaniem funkcji nie czystych.


Funkcje powodujące skutki uboczne

Funkcje powodujące skutki uboczne nie muszą zawierać instrukcji return. Jeśli nie ma instrukcji return, funkcja zwraca wartość undefined.

function yell(message) {
  alert(message + "!!");
}

yell("Hej");

Zakresy dostępności

Nazwy argumentów funkcji w jej wnętrzu są dostępne jako zmienne. Odnoszą się do wartości, które zostały w ich miejsce przekazane w wywołaniu funkcji i tak jak zwykłe zmienne utworzone wewnątrz funkcji, poza funkcją nie istnieją. Obok głównego środowiska istnieją też mniejsze, lokalne środowiska tworzone przez wywołania funkcji. Podczas szukania zmiennej wewnątrz funkcji najpierw przeszukiwane jest środowisko lokalne i dopiero, gdy nie uda się jej tam znaleźć przeszukiwane jest główne środowisko. Dzięki temu zmienne znajdujące się wewnątrz funkcji mogą „zasłaniać” zmienne z głównego środowiska o takich samych nazwach.

function alertIsPrint(value) {
  var alert = print;
  alert(value);
}

alertIsPrint("Troglodyci");

Zmienne znajdujące się w tym lokalnym środowisku są widoczne tylko dla kodu wewnątrz funkcji. Jeśli ta funkcja wywoła inną funkcję, ta nowo wywołana funkcja nie będzie „widzieć” zmiennych znajdujących się w zewnętrznej funkcji:

var variable = "najwyższy poziom";

function printVariable() {
  print("W funkcji printVariable zmienna variable ma wartość '" +
        variable + "'.");
}

function test() {
  var variable = "lokalna";
  print("W funkcji test zmienna variable ma wartość '" + variable + "'.");
  printVariable();
}

test();

Jeśli jednak funkcja jest zdefiniowana wewnątrz innej funkcji, jej lokalne środowisko bazuje na lokalnym środowisku, które je otacza, nie na środowisku głównym.

var variable = "najwyższy poziom";
function parentFunction() {
  var variable = "lokalna";
  function childFunction() {
    print(variable);
  }
  childFunction();
}
parentFunction();

To oznacza, że o tym, które zmienne są widoczne w funkcji decyduje położenie tej funkcji w tekście programu. W funkcji widoczne są wszystkie zmienne, które zostały zdefiniowane „nad” jej definicją, czyli zarówno zdefiniowane w funkcjach ją zawierających jak i w głównym środowisku programu. Ta zasada określania dostępności zmiennych nazywa się leksykalnym określaniem zakresu.


Osoby, które wcześniej programowały już w innych językach, mogą się spodziewać, że każdy blok kodu (znajdujący się między klamrami) tworzy osobne lokalne środowisko. W JavaScripcie tak nie jest. Tylko funkcje tworzą nowe zakresy. Zwykłych bloków można używać w taki sposób, jak w tym przykładzie:

var something = 1;
{
  var something = 2;
  print("Wewnątrz: " + something);
}
print("Na zewnątrz: " + something);

Nazwa something wewnątrz i na zewnątrz bloku dotyczy tej samej zmiennej. Mimo że takie bloki, jak ten pokazany w przykładzie można tworzyć, nie ma sensu tego robić. Większość zainteresowanych zgadza się, że jest to rysa na projekcie języka JavaScript i w ECMAScript Harmony zostanie dodana możliwość definiowania zmiennych przypisanych do bloków (słowo kluczowe let).


Poniżej znajduje się przykład, który może Cię zaskoczyć:

var variable = "najwyższy poziom";
function parentFunction() {
  var variable = "lokalna";
  function childFunction() {
    print(variable);
  }
  return childFunction;
}

var child = parentFunction();
child();

Funkcja parentFunction zwraca swoją wewnętrzną funkcję, a kod znajdujący się na dole wywołuje tę funkcję. Mimo że funkcja parentFunction zakończyła już działanie, lokalne środowisko, w którym zmienna variable ma wartość "lokalna" nadal istnieje i funkcja childFunction jej używa. Opisywane konstrukcja nazywa się zamknięciem (ang. closure).


Oprócz tego, że bardzo łatwo można określić, w której części programu dana zmienna jest dostępna patrząc na kształt tekstu tego programu, dzięki leksykalnemu określaniu zakresu można także „syntetyzować” funkcje. Używając niektórych zmiennych z funkcji zewnętrznej, można sprawić, aby wewnętrzna funkcja wykonywała różne działania. Wyobraźmy sobie, że potrzebujemy kilku różnych, ale bardzo podobnych do siebie funkcji. Jedna z nich dodaje 2 do swojego argumentu, druga — 5 itd.

function makeAddFunction(amount) {
  function add(number) {
    return number + amount;
  }
  return add;
}

var addTwo = makeAddFunction(2);
var addFive = makeAddFunction(5);
show(addTwo(1) + addFive(1));

Aby zrozumieć, jak to działa, musisz przestać traktować funkcje, jako pakiety wykonujące obliczenia i wziąć pod uwagę to, że zawierają one także środowiska. Funkcje najwyższego poziomu są wykonywane w środowisku najwyższego poziomu, to jest oczywiste. Natomiast funkcja zdefiniowana w innej funkcji zachowuje dostęp do środowiska, które istniało w tej funkcji w momencie, gdy była definiowana.

W związku z tym funkcja add w powyższym przykładzie, która jest tworzona po wywołaniu funkcji makeAddFunction, zawiera środowisko, w którym zmienna amount ma określoną wartość. Pakuje to środowisko wraz z instrukcją return number + amount do wartości, która następnie zostaje zwrócona przez zewnętrzną funkcję.

Gdy zwrócona funkcja (addTwo lub addFive) zostaje wywołana, tworzone jest nowe środowisko — w którym zmienna number ma wartość — jako podśrodowisko zapakowanego środowiska (w którym zmienna amount ma wartość). Następnie te dwie wartości zostają zsumowane i zwracany jest wynik.


Funkcje rekurencyjne

Zastosowane w JavaScripcie reguły określania zakresu dostępności zmiennych oprócz tego, że pozwalają na tworzenie funkcji zawierających zmienne o takich samych nazwach umożliwiają również funkcjom wywoływać same siebie. Funkcja wywołująca samą siebie nazywa się rekurencyjną. Rekurencja umożliwia wykonywanie różnych ciekawych działań. Spójrz na poniższą implementację funkcji power:

function power(base, exponent) {
  if (exponent == 0)
    return 1;
  else
    return base * power(base, exponent - 1);
}

Ta implementacja jest bliższa temu, jak matematycy definiują potęgowanie i dla mnie osobiście wygląda o wiele lepiej niż poprzednia wersja. Kod ten działa, jak pętla, chociaż nie użyto w nim instrukcji while czy for, ani nie ma w nim żadnego lokalnego skutku ubocznego. Wywołując samą siebie funkcja osiąga ten sam rezultat.

Jest jednak pewien poważny problem: w większości przeglądarek ten kod zostanie wykonany około 10 razy wolniej, niż poprzednia implementacja. Jest to spowodowane tym, że w języku JavaScript wykonanie prostej pętli jest o wiele szybsze niż wielokrotne wywołanie funkcji.


Kwestia wyboru między szybkością, a elegancją rozwiązania jest bardzo ciekawa. Występuje tylko wtedy, gdy trzeba zdecydować czy zastosować rekurencję, czy nie. Jest wiele przypadków, w których eleganckie, intuicyjne i często zwięzłe rozwiązanie można wyrazić w bardziej zawiły, ale wydajniejszy sposób.

W przypadku funkcji power mniej eleganckie rozwiązanie również jest wystarczająco proste, aby było je łatwo zrozumieć. Dlatego nie ma sensu zastępować go wolniejszą rekurencją. Jednak zdarzają się przypadki, w których problem jest tak skomplikowany, że możliwość poświęcenia trochę wydajności na rzecz czytelności staje się kuszącą propozycją.

Podstawowa zasada, powtarzana przez wielu programistów, w tym także i mnie, jest taka, że wydajnością nie należy się przejmować dopóki nie można udowodnić, że program działa zbyt wolno. Jeśli taki dowód się pojawi, należy dowiedzieć się, co jest przyczyną spowolnienia i w znalezionych miejscach eleganckie rozwiązania zastąpić wydajnymi.

Oczywiście to nie znaczy, że należy całkowicie przestać interesować się wydajnością. W wielu przypadkach, choćby w funkcji power, eleganckie rozwiązanie jest tylko nieznacznie prostsze od wydajniejszego. Są też sytuacje, w których doświadczony programista od razu dostrzeże, że proste rozwiązanie nigdy nie będzie wystarczająco szybkie.

Powodem dla którego tak się rozpisuję na ten temat jest to, że zaskakująco wielu programistów wręcz fanatycznie koncentruje się na wydajności, nawet w najdrobniejszych szczegółach. W wyniku tego powstają większe, bardziej skomplikowane i często zawierające więcej błędów programy, których napisanie wymagało więcej wysiłku, niż gdyby zastosowano eleganckie rozwiązania, a zysk wydajności jest w nich i tak marginalny.


Stos

Ale mówiliśmy o rekurencji. Z rekurencją ściśle związane jest pojęcie stosu. Gdy wywoływana jest funkcja, następuje przekazanie sterowania do tej funkcji. Gdy funkcja zwróci wartość, sterowanie jest zwracane do kodu, który tę funkcje wywołał. Podczas wykonywania funkcji komputer musi „pamiętać” kontekst, w którym funkcja ta została wywołana, aby wiedzieć, w którym miejscu kontynuować wykonywanie programu po jej zakończeniu. Miejsce, w którym ten kontekst jest przechowywany nazywa się stosem.

Nazwa stos wzięła się stąd, że w wywołanej funkcji może znajdować się wywołanie innej funkcji. Każde wywołanie funkcji powoduje zapisanie kolejnego kontekstu. Można to sobie wyobrazić, jako stos kontekstów. Gdy zostaje wywołana funkcja, bieżący kontekst zostaje „położony” na wierzchu stosu. Gdy funkcja zwraca wartość, pobierany i przywracany jest kontekst z wierzchu stosu.

W pamięci komputera musi być miejsce na zapisanie tego stosu. Gdy stos staje się za duży, komputer zgłasza błąd i wyświetla komunikat typu „brak pamięci dla stosu” czyli „za dużo rekurencji”. Trzeba o tym pamiętać pisząc funkcje rekurencyjne.

function kura() {
  return jajko();
}
function jajko() {
  return kura();
}
print(kura() + " była pierwsza.");

Oprócz bycia niezwykle ciekawym przykładem źle napisanego programu, powyższy kod stanowi też dowód na to, że funkcja nie musi wywoływać sama siebie, aby być rekurencyjna. Jeśli wywołuje inną funkcję, która (pośrednio lub bezpośrednio) wywołuje ją ponownie, to również jest to rekurencja.


Rekurencja jednak to nie tylko mniej wydajny sposób zapisu niektórych algorytmów. Niektóre problemy po prostu jest o wiele łatwiej rozwiązać przy jej użyciu. Najczęściej są to problemy wymagające przeglądania lub przetwarzania kilku „gałęzi”, z których każda może rozgałęziać się na dalsze gałęzie.

Zastanów się nad tą zagadką: jeśli zaczniemy od liczby 1 i będziemy wielokrotnie dodawać 5 albo mnożyć przez 3, to możemy otrzymać nieskończoną liczbę nowych liczb. Jak napisać funkcję pobierającą liczbę i próbującą znaleźć sekwencję działań dodawania i mnożenia, które pozwolą uzyskać tę liczbę.

Na przykład 13 można uzyskać mnożąc 1 przez 3, a następnie dwa razy dodając 5. Liczby 15 nie da się w ten sposób osiągnąć.

Oto rozwiązanie tego problemu:

function findSequence(goal) {
  function find(start, history) {
    if (start == goal)
      return history;
    else if (start > goal)
      return null;
    else
      return find(start + 5, "(" + history + " + 5)") ||
             find(start * 3, "(" + history + " * 3)");
  }
  return find(1, "1");
}

print(findSequence(24));

Należy zauważyć, że funkcja ta niekoniecznie znajduje najkrótszą sekwencję działań, ponieważ kończy działanie, gdy znajdzie jakąkolwiek.

Wewnętrzna funkcja find, wywołując sama siebie na dwa różne sposoby, sprawdza zarówno możliwość dodania 5 do bieżącej liczby jak i mnożenia jej przez 3. Gdy znajdzie liczbę, zwraca łańcuch history, w którym przechowywane są wszystkie operatory użyte do uzyskania tej liczby. Ponadto sprawdza czy bieżąca liczba jest większa od docelowej (goal), ponieważ, jeśli tak, należy przestać badać tę gałąź, gdyż wiadomo, że nie znajdziemy w niej szukanej liczby.

Użycie operatora || w tym przykładzie można odczytać następująco: „zwróć rozwiązanie znalezione poprzez dodanie 5 do start, a jeśli to się nie powiedzie, zwróć rozwiązanie znalezione poprzez pomnożenie start przez 3”. Można to też zapisać bardziej rozwlekle:

else {
  var found = find(start + 5, "(" + history + " + 5)");
  if (found == null)
    found = find(start * 3, "(" + history + " * 3)");
  return found;
}

Mimo że definicje funkcji znajdują się jako instrukcje między innymi instrukcjami programu, to należą do innej czasoprzestrzeni:

print("Przyszłość mówi: ", future());

function future() {
  return "nadal nie będziemy mieli latających samochodów.";
}

Komputer najpierw wyszukuje wszystkie definicje funkcji i zapisuje je, a dopiero potem rozpoczyna wykonywanie reszty programu. To samo dotyczy funkcji zdefiniowanych w innych funkcjach. Gdy wywołana zostaje zewnętrzna funkcja, najpierw wszystkie wewnętrzne funkcje zostają dodane do nowego środowiska.


Istnieje inny sposób na definiowanie wartości funkcji, który bardziej przypomina sposób tworzenia innych wartości. Gdy w miejscu, gdzie powinno znajdować się wyrażenie zostanie użyte słowo kluczowe function, traktuje się to jako wyrażenie zwracające wartość funkcji. Funkcje tworzone w ten sposób nie muszą mieć nazw, chociaż mogą.

var add = function(a, b) {
  return a + b;
};
show(add(5, 5));

Zwróć uwagę na średnik znajdujący się za definicją funkcji add. Normalnych definicji funkcji nie kończy się średnikiem, ale ta instrukcja jest pod względem struktury równoważna np. z instrukcją var add = 22;, a więc na jej końcu musi znajdować się średnik.

Taka wartość funkcyjna nazywa się funkcją anonimową, ponieważ funkcja ta nie ma nazwy. Czasami nadawanie funkcji nazwy nie ma sensu. Tak było w przypadku przykładu makeAddFunction:

function makeAddFunction(amount) {
  return function (number) {
    return number + amount;
  };
}

Ponieważ funkcja o nazwie add w pierwszej wersji funkcji makeAddFunction była użyta tylko raz, jej nazwa do niczego nie jest potrzebna i równie dobrze można było bezpośrednio zwrócić jej wartość.


Ćwiczenie 3.2

Napisz funkcję o nazwie greaterThan, która pobiera liczbę jako argument i zwraca funkcję reprezentującą test. Gdy ta zwrócona funkcja zostanie wywołana z jedną liczbą jako argumentem, powinna zwrócić wartość logiczną: true, jeśli podana liczba jest większa od liczby użytej do utworzenia funkcji testowej i false w przeciwnym przypadku.


Wypróbuj poniższy kod:

alert("Cześć", "Dobry wieczór", "Dzień dobry", "Do widzenia");

Funkcja alert oficjalnie przyjmuje tylko jeden argument. Jeśli jednak przekaże się jej więcej parametrów wywołania, komputer nie zgłosi żadnego błędu, tylko zignoruje wszystkie argumenty oprócz pierwszego.

show();

Jak widać może Ci się nawet upiec, jeśli podasz za mało argumentów. Jeśli argument nie zostanie podany, wewnątrz funkcji zostaje mu przypisana wartość undefined.

W następnym rozdziale dowiesz się, jak napisać funkcję dostosowującą się do listy argumentów, które zostaną do niej przekazane. Jest to przydatne, ponieważ dzięki temu można napisać funkcję przyjmującą dowolną liczbę argumentów. Z możliwości tej korzysta funkcja print:

print("R", 2, "D", 2);

Wadą tego jest to, że można przez przypadek przekazać nieodpowiednią liczbę argumentów do funkcji, która wymaga konkretnej liczby argumentów, jak np. alert, i interpreter nas o tym nie poinformuje.

Przypisy

  1. Z technicznego punktu widzenia nie jest to konieczne, ale twórcy języka JavaScript zapewne pomyśleli, że taki wymóg zmusi programistów do pisania bardziej przejrzystego kodu.
  2. Funkcja czysta nie może używać wartości zewnętrznych zmiennych. Wartości te mogą się zmieniać, przez co funkcja zwracałaby różne wyniki dla tych samych argumentów. W praktyce niektóre zmienne można traktować jako stałe — mające się nie zmieniać ― i za czyste uważać tylko te funkcje, które używają wyłącznie stałych. Dobrym przykładem stałych zmiennych są zmienne zawierające wartości funkcyjne.

Autor: Marijn Haverbeke

Źródło: http://eloquentjavascript.net/1st_edition/chapter3.html

Tłumaczenie: Łukasz Piwko

Treść tej strony jest dostępna na zasadach licencji CC BY 3.0