Programowanie funkcjonalne (lub funkcyjne), w skrócie FP, to paradygmat programowania, który koncentruje się na traktowaniu obliczeń jako ewaluacji funkcji matematycznych.
Wikipedia:
Podstawą teoretyczną programowania funkcyjnego jest rachunek lambda (a dokładnie rachunek lambda z typami). Został on opracowany w latach 30. XX wieku przez Alonzo Churcha. Nazwa paradygmatu po raz pierwszy pojawiła się w pracy Johna Backusa: "Can Programming Be Liberated From the von Neumann Style? A Functional Style and Its Algebra of Programs" w roku 1977, dzięki której Backus dostał nagrodę Turinga, mimo że programowanie funkcyjne było znane już wcześniej.
Popularne języki programowania funkcjonalnego to Haskell
, Scala
, Clojure
, Erlang
, Elixir
czy F#
.
Dodatkowo funkcjonalność programowania funcjonalnego dostępna jest w takich choćby językach jak Ruby
, Python
, Rust
, JavaScript
itd.
W poniższym mini-artykule skupimy się na programowaniu funkcjonalnym w odniesieniu do języka Elixir, choć będziemy się także wspomagać, królem Haskell'em.
Oto kilka kluczowych zasad i cech programowania funkcyjnego:
Dane niemutowalne tudzież niezmienne (immutable) jest kluczową zasadą programowania funkcyjnego.
Poniżej kilka argumentów za tym by dane były niemutowalne:
Czyste funkcje (pure functions) oraz efekty uboczne są kluczowymi elementami pomagającymi w pisaniu przewidywalnego i łatwego w utrzymaniu kodu.
Czyste funkcje to takie, które zawsze zwracają ten wynik dla tych samych argumentów i które nie mają efektów ubocznych.
Efekty uboczne to oczywiście wszelakie operacje mające wpływ na stan po za funkcją. Dla przykładu, język Elixir zachęca do używania czystych funkcji, zapewnia również mechanizmy do kontrolowanego zarządzania efektami ubocznymi. Często odbywa się to poprzez izolowanie efektów ubocznych od głównej logiki aplikacji:
defmodule Logger do
def log_message(message) do
IO.puts("Log: #{message}")
end
end
defmodule Math do
def add(a, b) do
result = a + b
Logger.log_message("Adding #{a} and #{b} to get #{result}")
result
end
end
IO.inspect(Math.add(2, 3)) # Wynik: 5
# "Log: Adding 2 and 3 to get 5"
W tym przykładzie, funkcja log_message/1
ma efekt uboczny (wydruk w konsoli). Funkcja add/2
pozostaje czysta w swojej głównej logice, ale wywołuje funkcję log_message/1
, aby obsłużyć efekt uboczny.
Dopasowywanie wzorców (ang. pattern matching) jest techniką w programowaniu funkcyjnym, która pozwala na sprawdzanie wartości względem określonego wzorca i, jeśli wartość pasuje do wzorca, wyodrębnianie z niej informacji. Jest to elastyczny i wyrazisty sposób podejmowania decyzji na podstawie struktury danych. Innymi słowy można przyrównać to do sprawdzania wartości prawej do lewej.
Jest to bardzo trafnie omówione w książce Learn Functional Programming with Elixir autorstwa Ulisses Almeida'y, bazując na przykładzie kodu:
1 = 1 # Wynik: 1
2 = 1 # Wynik: ** (MatchError) no match of right hand side value: 1
1 = 2 # Wynik: ** (MatchError) no match of right hand side value: 2
x = 1 # Wynik: 1
1 = x # Wynik: 1
1 = 1 matches, but 2 = 1 and 1 = 2 don’t because they’re different numbers.
(...)
You’re probably saying to yourself, "What a disappointment! It’s just a variable
assignment!" Maybe you’re thinking it’s a joke. But it’s not. It’s pattern
matching. Elixir is making both sides equivalent by binding the value 1 to the
variable x. (...)
The value is on the left side, the variable is on the right side, and it’s a valid
Elixir expression. It’s fascinating, right? We said previously that x = 1, and
now the value 1 is bound to the x variable. When we type 1 = x, Elixir tries to
check if the value on the left side is equal to the right side. If the two sides
are equal, then we have a valid expression.
Kluczowymi aspektami pattern matching'u są:
Poniżej przykład pattern matching'u w Haskellu:
-- Definicja typu danych
data Maybe a = Nothing | Just a
-- Funkcja z użyciem pattern matching
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:xs) = Just x
main :: IO ()
main = do
print (safeHead [1, 2, 3]) -- Wynik: Just 1
print (safeHead []) -- Wynik: Nothing
W tym przykładzie, funkcja safeHead
używa pattern matching do sprawdzenia, czy lista jest pusta. Jeśli jest pusta, zwraca Nothing
. Jeśli lista zawiera elementy, zwraca pierwszy element opakowany w Just
.
Inny przykład napisany w Elixirze:
defmodule Example do
def describe_list([]), do: "List is empty."
def describe_list([head | tail]), do: "The list starts with #{head} and has #{length(tail)} remaining elements."
end
IO.puts Example.describe_list([]) # Wynik: List is empty.
IO.puts Example.describe_list([1, 2, 3]) # Wynik: The list starts with 1 and has remaining elements.
W Elixirze, pattern matching jest używany do dopasowywania struktury listy. Funkcja describe_list
rozpoznaje, czy lista jest pusta, czy zawiera elementy, i odpowiednio zwraca opis.
W programowaniu funkcyjnym, pętle i rekurencja są kluczowymi technikami do wykonywania powtarzalnych operacji. Z tym, że w tzw. czystym programowaniu funkcjonalnym nie występują znane z obiektowego, pętle for
czy while
. Językami, w który one występują są Scala
, OCaml
czy F#
.
W tradycyjnych językach programowania, takich jak C czy Python, pętle for i while są powszechnie używane do iteracji.
Jednak w czystym programowaniu funkcyjnym, stosuje się funkcje wyższego rzędu, takie jak map
, filter
i reduce
:
list = [1, 2, 3, 4, 5]
# Mapujemy element listy do funkcji używając funkcji wyższego rzędu 'map'
squared_list = Enum.map(list, fn x -> x * x end)
IO.inspect(squared_list) # Wynik: [1, 4, 9, 16, 25]
Rekurencja (ang. recursion) to technika, w której funkcja wywołuje samą siebie, aby rozwiązać problem. Jest to podstawowy mechanizm iteracji w wielu językach funkcyjnych, takich jak Haskell czy Elixir.
Rekurencję można zobrazować na poniższym przykładzie:
defmodule Example do
def factorial(0), do: 1
def factorial(n), do: n * factorial(n - 1)
end
IO.puts Example.factorial(5) # Wynik: 120
Podsumowując, tematykę iteracji w programowaniu funkcjonalnym:
Funkcje wyższego rzędu to potężna cecha wielu innych języków programowania funkcyjnego.
Te funkcje albo przyjmują inne funkcje jako argumenty, albo zwracają je jako wyniki. Ta zdolność pozwala na bardziej abstrakcyjny i elastyczny kod.
Enum.map/2
do zastosowania funkcji do każdego elementu w liście.list = [1, 2, 3, 4]
squared_list = Enum.map(list, fn x -> x * x end)
IO.inspect(squared_list) # Wynik: [1, 4, 9, 16]
defmodule Math do
def adder(n) do
fn x -> x + n end
end
end
add_five = Math.adder(5)
IO.inspect(add_five.(10)) # Wynik: 15
(|>)
w języku Elixir do łączenia funkcji.defmodule StringManipulation do
def exclaim(string) do
string
|> String.trim()
|> String.capitalize()
|> Kernel.<>("!")
end
end
IO.inspect(StringManipulation.exclaim(" hello world ")) # Wynik: "Hello world!"
Podsumowując, funkcje wyższego rzędu dają następujące korzyści:
Domknięcie (ang. Closure) to funkcja, która przechwytuje powiązania wolnych zmiennych w swoim kontekście leksykalnym. Oznacza to, że funkcja zachowuje dostęp do tych zmiennych, nawet gdy jest wykonywana poza ich pierwotnym zakresem.
Wikipedia:
Domknięcie – w metodach realizacji języków programowania jest to obiekt wiążący funkcję lub referencję do funkcji oraz środowisko mające wpływ na tę funkcję w momencie jej definiowania. Środowisko przechowuje wszystkie nielokalne obiekty wykorzystywane przez funkcję. Realizacja domknięcia jest zdeterminowana przez język, jak również przez kompilator. Domknięcia występują głównie w językach funkcyjnych, w których funkcje mogą zwracać inne funkcje (tzw. funkcje wyższego rzędu), wykorzystujące zmienne utworzone lokalnie. Aby funkcje tego typu były możliwe, muszą one być typem pierwszoklasowym.
Przykład domknięcia w Elixirze:
defmodule Example do
def make_multiplier(factor) do
fn (number) -> number * factor end
end
end
multiplier_by_3 = Example.make_multiplier(3)
IO.inspect(multiplier_by_3.(10)) # Wynik: 30
W tym przykładzie, make_multiplier/1
zwraca funkcję, która mnoży swój argument przez podany czynnik w momencie wywołania make_multiplier/1
. Zwrócona funkcja jest zamknięciem, ponieważ przechwytuje zmienną factor ze swojego zakresu leksykalnego.
Rozwijanie funkcji (ang. Currying) to nic innego jak operacja polegająca na przeształceniu funkcji przyjmującej wiele argumentów w sekwencję funkcji, z których każda przyjmuje pojedyńczy argument.
Mimo, że Elixir nie obsługuje currying'u natywnie, można osiągnąć podobne zachowanie za pomocą domknięć i częściowej aplikacji:
defmodule Example do
def add(a) do
fn (b) -> a + b end
end
end
add_five = Example.add(5)
IO.inspect(add_five.(10)) # Wynik: 15
W tym przykładzie, add/1
zwraca funkcję, która przyjmuje kolejny argument i dodaje go do a
. Jest to forma 'u', gdzie funkcja add/1
jest częściowo zastosowana z pierwszym argumentem, a zwrócona funkcja przyjmuje drugi argument.
Natywny przykład currying'u można przedstawić z pomocą języka Haskell, od którego nazwiska się tak naprawdę wziął ów Currying (Haskell Curry):
-- Definicja funkcji, która dodaje dwie liczby
add :: Int -> Int -> Int
add x y = x + y
-- Użycie currying
addFive :: Int -> Int
addFive = add 5
main :: IO ()
main = do
print (addFive 10) -- Wynik: 15
W tym przykładzie, add
jest funkcją, która przyjmuje dwa argumenty typu Int
i zwraca ich sumę. Dzięki currying, możemy utworzyć nową funkcję addFive
, która jest częściowo zastosowaną wersją funkcji add
z pierwszym argumentem ustawionym na 5
. Następnie możemy użyć addFive
do dodania 5
do dowolnej liczby.
Częściowa aplikacja (ang. partial application) jest ściśle związana z "kurryfikacją". Polega na ustaleniu kilku argumentów funkcji i zwróceniu nowej funkcji, która przyjmuje pozostałe argumenty:
defmodule Example do
def add(a, b) do
a + b
end
def partially_apply_add(a) do
fn (b) -> add(a, b) end
end
end
add_five = Example.partially_apply_add(5)
IO.inspect(add_five.(10)) # Wynik: 15
W tym przykładzie, partially_apply_add/1
ustala pierwszy argument funkcji add/2
i zwraca nową funkcję, która przyjmuje drugi argument.
Podsumowując:
W programowaniu funkcjonalnym, wartościowanie odnosi się do sposobu, w jaki wyrażenia są oceniane i obliczane. Istnieją dwa główne podejścia do wartościowania:
-- Definicja nieskończonej listy liczb naturalnych
naturals :: [Int]
naturals = [0..]
-- Pobranie pierwszych 10 elementów z nieskończonej listy
main :: IO ()
main = print (take 10 naturals) -- Wynik: [0,1,2,3,4,5,6,7,8,9]
W powyższym przykładzie, lista naturals
jest nieskończona, ale dzięki leniwemu wartościowaniu, Haskell oblicza tylko te elementy, które są potrzebne do wykonania funkcji take 10
.
Jak widać, nie taki diabeł straszny jak go malują.
O ile programowanie funkcjonalne na pierwszy moment może wydawać się z goła odmienne od programowania obiektowego ze względu na choćby niemutowalność czy świadomość, że nie mamy tu obiektów ze stanami jak w przypadku programowania obiektowego. Czy to, że po prostu wszystko jest funkcją. I fakt, że zamiast zwykłych pętli for
i while
, mamy funkcje wyższego rzędu lub rekurencję.
Zyskujemy jednak zatem na bardziej modularnym i wymiennym kodzie, który łatwiej debug'ować.
Plusem przemawiającym za językami funkcjonalnymi jest też bardziej zwięzła składnia...
... Chyba, że zapragniemy zwiedzić odmęty piekielnych czeluści Haskella. Gdzie z uśmiechem diabła czekać na Nas będą functory i monady oraz szalenie zawiłe mechanizmy dodawania bibliotek i innych aspektów przygotowywania produkcyjnego kodu napisanego w Haskell'u. Choć i tu Cabal przychodzi niejako z pomocą.
Niemniej jednak mam nadzieję, że na moim kolejnym etapie drogi przez świat programowania - Elixir mnie nie zawiedzie. Oraz, że wraz z nauką tego języka jak i całego jego ekosystem'u, nie przyprawi mnie o depresję.
Gdy jednak już jej na serio zapragnę, po prostu otworzę książkę Haskell'em.
Lista źródeł: