To jest darmowy podgląd

To jest próbka 15 pytań z naszej pełnej kolekcji 43 pytań rekrutacyjnych. Uzyskaj pełny dostęp do wszystkich pytań ze szczegółowymi odpowiedziami i przykładami kodu.

Zobacz plany cenowe

Myślenie funkcyjne — wprowadzenie

Czym jest programowanie funkcyjne i czym różni się od podejścia imperatywnego?

Programowanie imperatywne opisuje program jako sekwencję poleceń (imperatywów), które krok po kroku modyfikują stan. Klasyczna pętla for jest tego wzorcowym przykładem: ustalasz stan początkowy i w każdej iteracji wykonujesz serię instrukcji, które ten stan zmieniają.

Programowanie funkcyjne opisuje program jako zbiór wyrażeń i transformacji, wzorowanych na formułach matematycznych, i stara się unikać mutowalnego stanu. Zamiast ręcznie sterować jak przejść po kolekcji, deklarujesz co chcesz uzyskać, a niskopoziomowe szczegóły (iterację, akumulację) zostawiasz językowi lub środowisku uruchomieniowemu.

Kluczowe jest to, że nauka programowania funkcyjnego to nie nauka nowej składni, lecz nauka innego sposobu myślenia — dostrzegania znanych problemów w nowych kategoriach (filtrowanie, transformacja, redukcja) zamiast w kategoriach pętli i zmiennych sterujących.

↑ Powrót na górę

Funkcje wyższego rzędu i czystość

Czym jest funkcja wyższego rzędu?

Funkcja wyższego rzędu to funkcja, która przyjmuje inną funkcję jako argument i/lub zwraca funkcję jako wynik. To fundament programowania funkcyjnego.

Mechanizm działa tak: język udostępnia generyczną „maszynerię" (np. filter, map, reduce), a Ty dostosowujesz ją do konkretnego problemu, przekazując funkcję jako parametr. Na przykład filter wie jak przejść po kolekcji i zbudować podzbiór — ale to Ty dostarczasz kryterium w postaci funkcji element -> wartość logiczna.

Dzięki temu zamiast pisać nową pętlę do każdego problemu, wielokrotnie używasz tych samych, dobrze zoptymalizowanych operacji, jedynie podstawiając różne funkcje.

↑ Powrót na górę

Czym jest funkcja czysta (pure function) i jakie ma zalety?

Funkcja czysta to funkcja bez efektów ubocznych: jej wynik zależy wyłącznie od argumentów, a jej wywołanie nie modyfikuje żadnego stanu na zewnątrz (pól obiektu, zmiennych globalnych, plików).

Zalety:

  • Testowalność — wystarczy podać argumenty i sprawdzić wynik; nie trzeba przygotowywać ani sprzątać stanu.
  • Brak współdzielonego stanu — taka funkcja jest naturalnie bezpieczna w środowisku wielowątkowym.
  • Przewidywalność — te same dane wejściowe zawsze dają ten sam wynik.
  • Ogólność — funkcje czyste są zwykle „samowystarczalne" i nadają się do ponownego użycia poza pierwotnym kontekstem.

Co istotne, jeśli funkcja jest czysta i nie trzyma wewnętrznego stanu, nie ma powodu jej „ukrywać" — można ją bezpiecznie udostępnić jako narzędzie ogólnego przeznaczenia.

↑ Powrót na górę

Podstawowe operacje na kolekcjach

Do czego służy operacja map?

Map przekształca kolekcję w nową kolekcję, stosując podaną funkcję do każdego elementu. Zwrócona kolekcja ma ten sam rozmiar co oryginalna — zmieniają się wartości, nie liczba elementów.

Przykładowo, mapując funkcję „długość" na liście słów, otrzymujesz listę liczb — długości tych słów. Funkcja przekazana do map może zwracać wartość zupełnie innego typu niż element wejściowy.

map to jedna z trzech podstawowych „cegiełek" przetwarzania kolekcji (obok filter i reduce). W różnych językach bywa nazywana inaczej — np. collect — ale idea pozostaje ta sama: transformacja każdego elementu w miejscu.

↑ Powrót na górę

Do czego służy operacja filter i czym różni się od find?

Filter tworzy nową, potencjalnie mniejszą kolekcję, zachowując tylko te elementy, które spełniają podane kryterium (funkcję zwracającą wartość logiczną). Rozmiar wyniku zależy od tego, ile elementów przejdzie test.

Find jest blisko spokrewniony, ale zwraca tylko pierwszy pasujący element, a nie cały podzbiór.

Operacja Zwraca
filter wszystkie pasujące elementy (kolekcja)
find pierwszy pasujący element

Ważna pułapka: gdy find niczego nie znajdzie, jedne języki zwracają null (zgodnie z konwencją Javy), a inne owijają wynik w typ Option (Some/None), eliminując dwuznaczność „brak wyniku kontra wartość null".

↑ Powrót na górę

Czym jest operacja reduce/fold i jak działa akumulator?

Reduce i fold przetwarzają kolekcję, łącząc jej elementy „kawałek po kawałku" w jeden wynik (który może być pojedynczą wartością albo mniejszą kolekcją). Obie używają akumulatora — wartości, w której gromadzony jest częściowy rezultat.

Działanie na przykładzie sumowania: zaczynasz od zera, dodajesz pierwszy element, wynik dodajesz do drugiego, i tak dalej, aż lista się wyczerpie, a akumulator zawiera końcową sumę. Funkcja przekazana do reduce to zwykle operator lub funkcja przyjmująca dwa argumenty i zwracająca jeden wynik.

Formalnie fold/reduce to katamorfizm — uogólnienie zwijania listy. Co istotne, żadna z tych operacji nie mutuje kolekcji — zwraca nowy wynik, nie naruszając oryginału.

↑ Powrót na górę

Czym różni się foldLeft od foldRight i kiedy kierunek ma znaczenie?

foldLeft zwija kolekcję „w lewo" — zaczyna od wartości początkowej i łączy ją kolejno z elementami od pierwszego do ostatniego. foldRight zwija „w prawo", odwracając kierunek stosowania operacji.

Kiedy to ma znaczenie:

  • Dla operacji przemiennych (jak dodawanie) kierunek jest obojętny — wynik będzie ten sam.
  • Dla operacji zależnych od kolejności (jak odejmowanie czy dzielenie) kierunek zmienia wynik — dlatego istnieją obie warianty.

W językach czysto funkcyjnych różnica sięga głębiej, do samej implementacji: fold prawostronny może operować na listach nieskończonych, a lewostronny nie. To istotne przy pracy z leniwymi, potencjalnie nieskończonymi strukturami danych — wybór wariantu fold decyduje wtedy o tym, czy obliczenie w ogóle się zakończy.

↑ Powrót na górę

Leniwość i strumienie

Czym jest leniwa ewaluacja (lazy evaluation)?

Leniwa ewaluacja to odraczanie obliczeń do momentu, gdy ich wynik jest faktycznie potrzebny. Zamiast natychmiast wyliczać wszystkie wartości, struktura przechowuje przepis na ich wytworzenie i uruchamia go dopiero na żądanie.

Pozwala to inaczej myśleć o strukturach danych. Możesz np. zdefiniować potencjalnie nieskończoną sekwencję, bo nigdy nie materializujesz jej w całości — pobierasz tylko tyle elementów, ile potrzebujesz.

Leniwość bywa wbudowana w język lub dostępna przez biblioteki. Jej praktyczna wartość to unikanie zbędnej pracy: jeśli po drodze odfiltrujesz większość elementów i weźmiesz tylko kilka pierwszych wyników, leniwy mechanizm w ogóle nie policzy reszty.

↑ Powrót na górę

Czym jest strumień (stream) i na czym polega metafora energii potencjalnej i kinetycznej?

W fizyce energia dzieli się na potencjalną (zmagazynowaną, gotową do użycia) i kinetyczną (wydatkowaną). Ta sama metafora świetnie opisuje różnicę między tradycyjną kolekcją a strumieniem:

  • Klasyczna kolekcja działa jak energia kinetyczna — natychmiast wylicza i przechowuje wszystkie wartości.
  • Strumień działa jak energia potencjalna — przechowuje jedynie źródło danych oraz kryteria (np. nałożone filtry), ale nie wytwarza wartości, dopóki o nie nie poprosisz.

Strumień można przekazywać jako parametr i dokładać do niego kolejne kryteria, póki pozostaje „potencjalny". Zamiana na „kinetyczny" — czyli faktyczne wyliczenie wartości — następuje dopiero przy operacji terminującej. To praktyczny przejaw leniwej ewaluacji.

↑ Powrót na górę

Współbieżność, równoległość i abstrakcje

Dlaczego operacje map/filter/reduce ułatwiają zrównoleglenie kodu?

Kod wielowątkowy należy do najtrudniejszych i najbardziej podatnych na błędy. Gdy sam sterujesz iteracją w pętli, musisz ręcznie wpleść w nią zarządzanie wątkami.

Operacje funkcyjne odwracają tę sytuację. Skoro wyrażasz co chcesz zrobić (przekształć każdy element, odfiltruj według kryterium), a nie jak przejść po kolekcji, środowisko ma swobodę wykonania tego równolegle. W wielu językach włączenie równoległości to dosłownie drobna zmiana w deklaracji (np. dodanie odpowiedniego modyfikatora albo użycie równoległego wariantu strumienia) — bez przepisywania logiki.

Umożliwiają to dwie cechy: brak współdzielonego mutowalnego stanu (funkcje przekazywane do map/filter są zwykle czyste) oraz to, że to runtime, a nie Ty, kontroluje przebieg iteracji. Środowisko może wtedy oprzeć się np. na bibliotece typu Fork/Join, by rozłożyć pracę na wątki.

↑ Powrót na górę

Domknięcia (closures)

Czym jest domknięcie (closure)?

Domknięcie to funkcja, która niesie ze sobą niejawne powiązanie ze wszystkimi zmiennymi, do których się odwołuje. Innymi słowy, funkcja „domyka" (otacza) kontekst wokół rzeczy, których używa — stąd nazwa.

Gdy domknięcie powstaje, tworzy obudowę wokół zmiennych widocznych w zakresie, w którym je zdefiniowano. Każde wystąpienie (instancja) domknięcia ma własne, unikalne kopie tych zmiennych — nawet jeśli pochodzą one z tej samej funkcji wytwórczej.

Od strony implementacji domknięcie trzyma zamkniętą kopię tego, co było w zasięgu w chwili jego utworzenia. Gdy samo domknięcie zostaje zebrane przez garbage collector, zwalniane są też przechowywane przez nie referencje.

↑ Powrót na górę

Currying i aplikacja częściowa

Czym jest currying?

Currying to przekształcenie funkcji wieloargumentowej w łańcuch funkcji jednoargumentowych. Opisuje proces transformacji funkcji, a nie sposób jej wywołania.

Po scurryowaniu funkcja process(x, y, z) przyjmuje postać process(x)(y)(z), gdzie zarówno process(x), jak i process(x)(y) są funkcjami przyjmującymi pojedynczy argument. Wywołujący decyduje, ile argumentów podać — podanie części z nich tworzy pochodną funkcję oczekującą reszty.

Currying wywodzi się z matematyki (nazwa pochodzi od nazwiska Haskella Curry'ego) i występuje w niemal każdym języku funkcyjnym, choć implementowany na różne sposoby. W językach dynamicznie typowanych ze zmienną liczbą argumentów bywa wręcz pomijany jako osobna cecha, bo aplikacja częściowa pokrywa potrzebne przypadki.

↑ Powrót na górę

Czym jest aplikacja częściowa (partial application)?

Aplikacja częściowa to przekształcenie funkcji wieloargumentowej w funkcję przyjmującą mniej argumentów, przez wcześniejsze podanie (ustalenie) wartości dla niektórych z nich. Nazwa jest trafna: częściowo aplikujesz część argumentów, otrzymując funkcję o sygnaturze złożonej z pozostałych.

Przykład intuicyjny: mając funkcję withTax(koszt, województwo), jeśli w danym fragmencie kodu pracujesz wyłącznie z jednym województwem, możesz częściowo zaaplikować ten argument i otrzymać locallyTaxed(koszt) — funkcję jednoargumentową, która nie „nosi" już zbędnego, powtarzanego parametru.

Aplikacja częściowa ustala konkretne, dostarczone przez Ciebie wartości, produkując funkcję o mniejszej arności (liczbie argumentów).

↑ Powrót na górę

Jaka jest różnica między curryingiem a aplikacją częściową?

Dla pobieżnego obserwatora dają ten sam efekt — w obu tworzysz wersję funkcji z częścią argumentów podanych z góry. Różnica tkwi w tym, co zostaje zwrócone, i ujawnia się dopiero przy funkcjach o arności większej niż dwa:

Currying Aplikacja częściowa
Istota zamiana w łańcuch funkcji jednoargumentowych ustalenie części argumentów z góry
Wynik dla process(x, y, z) po podaniu x process(x) → funkcja jednoargumentowa, która zwraca kolejną funkcję jednoargumentową funkcja process(y, z) o mniejszej arności
Co zwraca następną funkcję w łańcuchu funkcję o mniejszej liczbie argumentów

Różnica bywa subtelna i często mylona — niektóre języki nazywają „curryingiem" mechanizm, który tak naprawdę realizuje aplikację częściową. Mimo to rozróżnienie jest istotne: currying przechodzi przez łańcuch funkcji jednoargumentowych, a aplikacja częściowa od razu redukuje arność.

↑ Powrót na górę

Obsługa braku wartości

Jaki problem rozwiązuje typ Option (Some/None)?

Częstym źródłem nieporozumień jest null: czy to prawidłowa wartość zwracana, czy oznaka braku wartości? Ta dwuznaczność prowadzi do błędów i defensywnych sprawdzeń rozsianych po całym kodzie.

Option (w niektórych językach Optional, Maybe) usuwa tę dwuznaczność. Ma dwa możliwe stany:

  • Some — opakowuje faktycznie zwróconą wartość,
  • None — jawnie oznacza brak wyniku.

Dzięki temu sygnatura funkcji od razu komunikuje, że wynik „może go nie być", a odbiorca jest zmuszony świadomie obsłużyć przypadek None. Na przykład funkcja find może zwracać Some(wartość) przy trafieniu i None, gdy nic nie pasuje — zamiast zwracać null, którego znaczenie jest niejasne.

↑ Powrót na górę

Chcesz więcej pytań?

Uzyskaj dostęp do 800+ pytań z 13 technologii - JavaScript, React, TypeScript, Node.js, SQL i więcej. Wybierz plan 30-dniowy, 90-dniowy lub bezterminowy.

Wybierz plan od 49 zł