Na obsah stránky

Funkcionální přístup ke kolekcím

Aleš Roubíček | | # permalink

Když vidíte začátečníka pracovat s kolekcemi, často se setkáte s tím, že možností první volby je iterace pomocí cyklů a změna stavu. Díky tomu máme spoustu rozbitých aplikací. Ruku na srdce, kdo z vás nemá ve své codebase něco podobného:

var list = new List();
foreach (var item in someCollection) {
  // ... some work
  list.Add(item);
}
return list;

Nádhera! :)

Zapomeňte na cykly a mutace, kdo se v tom má vyznat a hledat chyby? Buďte deklarativní.

Disclaimer

Následující řádky používají techniku obvyklou ve funkcionálních jazycích – rekurzi. Je dost možné, že váš jazyk nemá podporu na úrovni kompilátoru pro rekurzi (tail recursion se optimalizuje tak, že se přepíše na cyklus) a může docházet k tomu, že vám bude přetékat zásobník. V první řadě vždy musíte vědět, co děláte. Ale to platí obecně o všem v programování. C# optimalizovat rekurzi neumí, přesto můžete používat funkcionální koncepty, např. LINQ, který je implementovaný pomocí iterátorů. Následující ukázky budou v F#, který rekurzi optimalizovat umí.

Všechny ukázky si můžete vyzkoušet přímo ve vašem prohlížeči na webu Try F#. Jednotlivé ukázky stačí označit a stisknout Ctrl+Enter.

Článek vychází z Higher-order list operations.

Rekurze

Když zjistíte, že cykly ne, většinou přejdete na rekurzi. Častou chybou však je, že píšete příliš mnoho kódu. Proč? Protože vám třeba chybí znalosti o knihovnách nebo konstruktech jazyka, které nám umožní psát kódu méně. Pojďme si ukázat, jak psát kódu méně a jak některé knihovní funkce vlastně fungují.

Přičítání jedné

Máme za úkol napsat funkci, která projde seznam čísel a přičte ke každému prvku jedničku. V implementaci použijeme datovou strukturu spojový seznam (v C# spíše než LinkedList<T> použijeme ImmutableList<T> z NuGet balíčku Microsoft.Bcl.Immutable) a využijeme rekurze:

let rec add1 lst =
    if List.isEmpty lst then []
    else (List.head lst) + 1 :: add1 (List.tail lst)

add1 [1; 2; 3]

Že jde o rekurzivní funkci nám říká klíčové slovo rec. Funguje to tak, že vezmu první prvek seznamu a přičtu k němu jedničku, ten pak připojím k seznamu (operátor ::), který se spočítá obdobně ze zbytku vstupního seznamu. Pokud je seznam prázdný (nebo jsme došli na konec – každý spojový seznam končí prázdným polem), vrátíme prázdný seznam. Tím se nám rekurze ukončí.

Odčítání jedné

Obdobně naimplementujeme i odčítání:

let rec sub1 lst =
    if List.isEmpty lst then []
    else (List.head lst) - 1 :: sub1 (List.tail lst)

sub1 [1; 2; 3]

Ok, máme funkcionální datovou strukturu, máme rekurzi, je to už teda funkcionální kód?

Tak určitě ne.

Knihovní funkce

Další častou chybou je používání podmínek a ukecaného kódu vůbec. Navíc když se na obě funkce podíváme blíže, zjistíme, že to jsou v podstatě duplicity a jediné, co je odlišuje, je operace sčítání nebo odčítání. Když se oprostíme od implementačních detailů, zjistíme, že neděláme nic jiného než mapování. A tak jde kód přepsat do jednodušší podoby:

let inc x = x + 1
let dec x = x - 1

List.map inc [1; 2; 3]
List.map dec [1; 2; 3]

Nejprve jsme si definovali pomocné funkce pro inkrementaci a dekrementaci prvku a pak použili knihovní funkci List.map, která projde všechny prvky seznamu a na každý aplikuje předanou funkci (v C# je obdobou Enumerable.Select). Důležitým detailem, který nemusí být na první pohled zřejmý, je, že nedojde k úpravě předaného seznamu, ale k vytvoření seznamu nového.

High order function

Jak bychom takovou funkci map naimplementovali? Zkusíme to pomocí jednoduché generalizace našich funkcí add1 a sub1. Už jsme si říkali, že se liší jen v operaci přičtení/odečtení jedničky. Takže z našich funkcí vytvoříme funkci vyššího řádu:

let rec map f lst =
    if List.isEmpty lst then []
    else f(List.head lst)::map f (List.tail lst)

map inc [1 .. 3]

Funkce vyššího řádu se vyznačují tím, že jako parametr přijímají jinou funkci, která definuje, co se uvnitř bude dít (obdoba vzoru strategie z objektového světa). Funkce je takřka stejná jako naše konkrétní ukázky. Jediný rozdíl je, že nyní aplikujeme předanou funkci na první prvek seznamu a pak už je to stejný. Teď tedy máme funkci vyššího řádu, ale pořád je toho kódu zbytečně moc.

Pattern matching

Další skvělou vlastností mnoha funkcionálních jazyků je pattern matching – volba výsledku na základě strukturální podobnosti. Naši map funkci můžeme hezky zjednodušit na:

let rec mapMatch f lst =
    match lst with
    | [] -> []
    | head::tail -> f head::mapMatch f tail

mapMatch dec [1 .. 3]

Kód je de facto stejný jako v předchozím případě, jen nám zmizelo volání knihovních funkcí List.isEmpty, List.head a List.tail a místo nich máme vzory. Prázdný seznam se mapuje na prázdný seznam, seznam s nějakým prvkem se dle vzoru rozdělí na head a tail. Na head se aplikuje funkce a nový tail se vypočítá rekurzí.

List comprehension

Možná jste si všimli, že jsem v ukázkách najednou přestal psát [1; 2; 3;] pro vytvoření seznamu tří čísel, ale přešel k zápisu [1 .. 3]. Funkcionální jazyky mívají pro kolekce větší pochopení a tak nezvládají jen tupé vyjmenování prvků, ale rozumí i složitějším konstrukcím pro vytvoření seznamu (platí i pro pole a sekvence). Zde byl použit operátor rozsahu ... Ve skutečnosti můžeme jít mnohem dál a aplikovat třeba rovnou i mapování:

[for x in 1 .. 3 -> x + 1]

Výsledek je stejný jako v předchozích příkladech. Kód je minimální. Myšlenka zřejmá.

Filtrování kolekcí

Další možností, kde můžeme využít funkce vyššího řádu a pokusíme se zbavit podmínek, je filtrování kolekcí. Funkce filter vrací prvky ze vstupního pole, které splňují předaný predikát:

let even x = x % 2 = 0

let rec filter p lst =
    if List.isEmpty lst then []
    elif p (List.head lst) then List.head lst::filter p (List.tail lst)
    else filter p (List.tail lst)

filter even [1 .. 10]

Nejprve jsme si definovali predikát pro sudá čísla, který budeme používat i v dalších ukázkách, a pak funkci filter implementovanou pomocí podmínek a knihovních funkcí (F# má samozřejmě funkci List.filter již implementovanou, v C# ji hledejte pod názvem Enumerable.Where). Filtr je o něco složitější než mapování, protože se musíme rozhodnout, které prvky chceme a které ne. Ty prvky, které splňují predikát připojíme před zbytek počítaného seznamu. Pokud prvek predikát nesplňuje, pokračuje dál ve výpočtu seznamu. U prázdného seznamu ukončíme rekurzi.

S využitím pattern matchingu kód vypadá následovně:

let rec filterMatch p lst =
    match lst with
    | [] -> []
    | hd::tl when p hd -> hd::filterMatch p tl
    | hd::tl -> filterMatch p tl

filterMatch even [1 .. 10]

Pěkné, že? A ještě ukázka s list comprehension:

[for x in 1 .. 10 do if even x then yield x]

Všimněte si, že zde se nám šipka -> z ukázky pro mapování změnila na do ... yield. Schválně si to zkuste, že jde o stejný kód. Tady už bohužel kompaktní zápis nefunguje, protože tam potřebujeme vložit filtrovací podmínku.

Agregace kolekcí

Další častou operací, kterou se seznamy můžeme dělat, je jejich agregace. Ve funkcionálních jazycích se často můžeme potkat s funkcí fold (v C# Enumerable.Aggregate):

let rec foldr cons nil lst =
    match lst with
    | [] -> nil
    | hd::tl -> cons hd (foldr cons nil tl)

foldr (+) 0 [1 .. 4]

Parametr cons je konstrukční funkce výsledku, nil reprezentuje prázdný prvek.

A ještě tail recursive varianta pro efektivnější zpracování kompilátorem:

let rec foldl cons nil lst =
    match lst with
    | [] -> nil
    | hd::tl -> foldl cons (cons hd nil) tl

foldl (+) 0 [1 .. 4]

Zipování

Další velice důležitou funkcí je zip, která vezme dva seznamy a vytvoří z nich jeden tvořený dvojicemi, podobně jako zip u vaší bundy.

let rec zip lst1 lst2 =
    match lst1, lst2 with
    | [], [] -> []
    | hd1::tl1, hd2::tl2 -> (hd1, hd2)::zip tl1 tl2

zip [1 .. 3] ['a' .. 'c']

Žádný velký objev. :) Novinkou je matchování na více prvků a zavedení n-tic (tuples). Zip je velice šikovná funkce. Schválně jestli vás napadne nějaké rozumné užití. Nebojte se o něj podělit třeba v komentářích.

Také si všimněte, že tu mícháme jabka (int) s hruškama (char) a vše funguje. Není to tím, že by F# byl slabě typový, ale tím, že naše funkce jsou plně generické! Schválně si zkuste v předchozích ukázkách změnit vstupní data.

Rozepnutí

Opakem zipování je unzip, která nám ze seznamu dvojic udělá dva seznamy prvků. A protože nám tato funkce vrací dva seznamy, začíná to být trochu tricky:

let rec unzip lst =
    match lst with
    | [] -> ([], [])
    | (x, y)::tl ->
        let (xs, ys) = unzip tl
        (x::xs, y::ys)

unzip [(1, 'a'); (2, 'b'); (3, 'c')]

Taková nepěkná věc je, že si musíme začít pamatovat výsledek rekurzivního volání, se kterým dále pracujeme. Všimněte si také, že F# umí rozpadnout n-tici do samostatných chlívečků. Nebudu říkat proměnných, protože let vazba je neměnná.

Continuation

Co bychom to byly za funkcionáře, kdybychom nechali takové imperativní přiřazování v naší jinak čisté implementaci? Jak se jen zbavit přiřazení? Co třeba za pomocí continuation? Nic vám to neříká? Co třeba callback? Ten už jistě znáte. :)

let rec unzipk k lst =
    match lst with
    | [] -> k [] []
    | (x, y)::tl -> unzipk (fun xs ys -> k (x::xs) (y::ys)) tl

unzipk (fun xs ys -> xs) [(1, 'a'); (2, 'b'); (3, 'c')]

V této formě musí uživatel funkce přidat callback, ale na druhou stranu si může vybírat, co vlastně chce za výsledek. Funkcionální kompozice je mocná.

Partitioning

Partitioning je něco jako filtrování, jen nám opět vrací dva seznamy. Jeden s pozitivními výsledky a druhý s negativními:

let rec partition p lst =
    match lst with
    | [] -> ([], [])
    | hd::tl ->
        let (ins, outs) = partition p tl
        if p hd then (hd::ins, outs)
        else (ins, hd::outs)

partition even [1 .. 10]

Implementace je dost podobná unzipování, jen teď máme na vstupu pouze jeden prvek a rozhodujeme se, do kterého seznamu ho zařadíme. A opět můžeme implementaci udělat pomocí continuation:

let rec partitionk p k lst =
    match lst with
    | [] -> (k [] [])
    | hd::tl -> partitionk p (fun ins outs ->
                                if p hd then k (hd::ins) outs
                                else k ins (hd::outs)) tl

partitionk even (fun evens odds -> evens) [1 .. 10]

Závěr

Ukázali jsme si, jak si ušetřit spoustu práce pomocí funkcionálního přístupu ke kolekcím. Implementace jednotlivých funkcí je jen ukázkou, jak snadno se dají takové věci implementovat a rychlým průletem, co všechno F# umí. Sice jsme se jen sklouzli po povrchu, ale jako základ je to celkem slušný, ne? :)

Zkuste si pohrát se spustitelnými ukázkami. Zkuste mapování a filtrování implementovat pomocí continuation. Zkuste si třeba filter implementovat pomocí partition. Většina kombinátorů lze totiž implementovat pomocí pouhých tří základních, ale k tomu zase třeba jindy.

Našli jste v článku chybu? Máte námět na reportáž? Založte mi ticket.

Reakce v síti

Líbilo se

  • Načítají se data…

Přeposláno dál

  • Načítají se data…

Komentáře

Okomentováno