Functional Programming in Java

Okładka Functional Programming in Java

Function

Ma domyślne metody:

funkcja.compose(wcześniejszaFunkcja)składa wywołania funkcji, parametr będzie najpierw przekazany do wcześniejszejFunkcji a wynik do funkcji

funkcja.andThen(późniejszaFunkcja) przekaże parametr do funkcji a rezultat do późniejszejFunkcji

funkcja.identity() zwraca przekazany parametr

Predicate

Ma domyślne metody and(innyPredykat), or(innyPredykat) i negate() oraz statyczną isEqual(obiekt) która zwraca predykat porównujący, z wykorzystaniem equals, z przekazanym obiektem.

Unary/Binary Operator

Rozszerzają odpowiednio Function i BiFunction ale operują tylko na jednym typie generycznym

UnaryOperator ma identity() który zwraca ten sam UnaryOperator a BinaryOperator ma minBy(comparator) oraz maxBy(comparator) które porównują dwie wartości.


Checked Exceptions

Interfejsy z java.util.function nie deklarują żadnych sprawdzanych wyjątków dlatego muszą być one łapane lub przepakowywane wewnątrz lambdy. Ewentualnie można utworzyć interfejs który deklaruje wyjątki ale i tak nie będzie możliwy do użycia w streamach:

interface UseInstance<T, X extends Throwable> {
void accept (T instance) throws X;
}

Jeśli koniecznie z lambdy musi wydostać się sprawdzany wyjątek wtedy:

  • unsafe.throwException(new IOException()) działa
  • Thread.currentThread().stop(new IOException()) już nie działa
  • ograniczony parametr typu i rzutowanie działa:
private static <T extends Throwable> void sneakyThrow(Throwable exception) throws T {
throw (T) exception;
}

Stream

Streamy są leniwe więc zmiana źródła danych z którego powstał stream przed wywołaniem metody kończącej zmienia stream. Zmiana źródła w czasie przeglądania streamu jest bezpieczna tylko dla bezpiecznych wielowątkowo źródeł (ConcurrentHashMap).

Niektóre sposoby stworzenia streama:

  • Stream.of(varargs)
  • Stream.generate(supplier)
  • Stream.iterate(poczatkowaWartość, zmianaWartości)
  • Stream.concat(jakiśStream, innyStream)
  • Stream.builder().add("element").build()

ZmianaWartości w Stream.iterate() może polegać np. na zwróceniu następnej liczby pierwszej.

Ograniczanie streama przez limit i skip

Przejście do streamów prymitywnych przez mapToInt/Double/Long, są też wersje flatMapTo

Primitive Stream

Przejście do Streamu obiektowego przez mapToObject

Przy wyliczaniu średniej zwracają prymitywne Optionale np. OptionalDouble, OptionalInteger, OptionalLong – od zwykłego Optionala różnią się tym, że opakowują typ prosty i mają metodę getTypProsty, dodatkowo orElse wymaga Suppliera tego typu prostego.

sum na typie prostym zwraca wartość bez Optionala – dla pustego streamu zwraca 0

CharSequence (String, StringBuilder…) ma domyślną metodę chars która zwraca IntStreama. To Integery nie Charactery przez znaki uzupełniające Unicode które nie mieszczą się w zakresie char a kompatybilnośc wsteczna nie pozwala go rozszerzyć.

Summary Statistics

Dają dostęp do max, min, average, count
Do statystki można dodawać kolejne wartości (accept) albo łączyć kilka statystyk ze sobą (combine)
wszystkie interfejsy dla streamów prymitywnych mają metody asTypPrymitywny – wyjątki do consumer i predicate – te mają standardowe nazwy


Collectors

stream.collect(supplier, consumer, consumer) pierwszy dostarcza pusty obiekt drugi to akumulator a trzeci combiner
stream.collect przyjmuje też obiekt Collector który implementuje 3 powyższe metody plus finisher i characteristics (concurrent, unordered, identityFinish – czyli finisher nic nie robi).

toMap

toMap(keyMapper, valueMapper) przyjmuje dwie funkcje z których jedna to często Function.identity(), jeśli klucz jest zdublowany wtedy jest rzucany IllegalStateException

toMap(keyMapper, valueMapper, mergeFunction) jak poprzednia ale w razie wystąpienia duplikatu jest on mergowany za pomocą mergeFunction

toMap(keyMapper, valueMapper, mergeFunction, mapSupplier) pozwala wybrać typ mapy, domyślnie to HashMapa

Wszystkie trzy funkcje mają swoje bezpieczne wielowątkowo odpowiedniki: toConcurrentMap

groupingBy

groupingBy zwraca mapę w której wartością jest lista, groupingBy w groupingBy zwraca mapę map.

GroupingBy ma trzy warianty:

  • groupingBy(funkcjaKlasyfikująca)
  • groupingBy(funkcjaKlasyfikująca, collectorDlaDownstream)
  • groupingBy(funkcjaKlasyfikująca, fabrykaMapy, collectorDlaDownstream)

Wszystkie trzy mają też odpowiedniki bezpieczne wielowątkowo.

collectorDlaDownstream umożliwia dalsze przetwarzanie wartości w mapie przez zastosowanie dowolnego Collectora, jeśli klucz i wartość są na innych poziomach zagnieżdżenia, lub wymagają modyfikacji, przydatny jest Collectors.mapping, który mapuje wartości. W javie 9 dodano analogiczne flatMapping i filtering.

partitioningBy

Przyjmuje predykat i zwraca mapę która ma zawszę dwie wartości True i False (niezależnie czy mają jakieś wartości).

Ma tylko dwa warianty:

  • partitioningBy(predykat)
  • partitioningBy(predykat, collectorDlaDownstream)

Nie można określić rodzaju mapy, np wykorzystać wersję bezpieczną wielowątkowo. Nie ma bezpiecznych wielowątkowo metod partitioningBy.

Redukujące

  • joining()
  • joining(delimiter)
  • joining(delimiter, prefix, sufix)

Działa analogicznie do String.join(delimiter, stringi). Pierwszy łączy Stringi, drugi łączy i dodaje między nimi delimiter (poza ostatnim). Trzeci umożliwia poprzedzenie i zakończenie całego ciągu.

  • maxBy(comparator)
  • minBy(comparator)

Zwracaja Optionale i nie mają wersji z domyślną wartością tak samo jak ich odpowiedniki dla streama.

counting() – zwraca longa

Reduce

Od Collectora różni się tym, że typ zwracany musi być taki sam jak typ elementów i w teorii każde wywołanie tworzy nowy element. W praktyce nic poza konwencją nie stoi na przeszkodzie żeby reduce mutowało przekazywane mu obiekty i je zwracało. Dodatkowo w jednym przypadku zwracany typ może być inny niż typ streama.

Zarówno Collectors.reducing() jak i stream.reduce() mają trzy wersję:

  • reducing(redukującyBinaryOperator) – zwraca Optionala
  • reducing(wartośćPoczątkowa, redukującyBinaryOperator) – dla pustego streamu zwraca wartośćPoczątkowa, dla niepustego wartośćPoczątkowa jest pierwszym argumentem redukującegoBinaryOperatora
  • reducing(wartośćPoczątkowa, mapper, redukującyBinaryOperator) – jak wyżej ale przed rozpoczęciem redukcji najpierw wartość jest mapowana, ta sygnatura metody pozwala na redukowanie do typu wartościPoczątkowej która może być inna niż typ streamu, analogiczna metoda Stream.reduce(wartośćPoczątkowa, redukującaBiFunkcja, combiner) jest wykorzystywana przy przetwarzaniu wielowątkowym a combiner łączy rezultaty uzyskane w podzadaniach.

Arytmetyczne

Wszystkie zwracają Double, 0 jeśli stream jest pusty, czyli inaczej niż dla prymitywnych streamów gdzie jest zwracany odpowiedni Optional.

averangingDouble/Int/Long(maper Object -> odpowiedni typ prosty) -dają dokładniejsze wyniki przy ciągu rosnącym.

summarizingDouble/Int/Long() – zwraca statystyki bez konieczności przechodzenia na stream dla typów prostych

summingDouble/Int/Long()– prostsza wersja powyższego z samym sumowaniem

Do kolekcji

  • toList()
  • toSet()
  • toCollection(konstruktorKolekcji)

Dwa pierwsze dają niegwarantowane implementacje (ArrayList i HashSet), trzeci wymusza podanie konstruktora samemu

Wrapper

collectingAndThen(downstreamCollector, funkcjaOpakowująca) – wynik działania downstreamCollectora zostanie przekazany funkcjiOpakowywującej i to jej rezultat zostanie zwrócony. Funkcja może np. opakowywać kolekcję w Collections::unmodifiableCollection.

Przy implementowaniu Collectora samodzielnie funkcjaOpakowująca to finisher.

Typy proste

Mają typy ObjTypPrymitywnyConsumer która przyjmuje T i typ prosty i ma metodę accept.


Lambdy

Zmienne w lambdach domyślnie nie są finalne. Jeśli mają być wtedy trzeba podać też typ.

Pole jest zawsze Effectively final: 

class EffectivelyFinal {
int value = 1;

void method(){
value = 2;
produce(() -> value);
value = 3;
}

int produce(Supplier<Integer> supplier) {
return supplier.get();
}
}

Ten kod się skompiluje bo value jest polem i odniesienie ma postać this.value. A this jest Effectively final.

Jeśli funkcja zależy od zmiennej spoza niej (domknięcie) wtedy można zrobić funkcję która przyjmuję tą wartość i zwraca funkcję która ją wykorzystuje.
Można zawęzić zasięg wewnętrznej funkcji tylko do zewnętrznej (odpowiednik where w Haskelu) przez zwracanie lambdy jako wyniku lambdy.

final Function<String, Predicate<String>> startsWithLetter = 
letter -> name -> name.startsWith(letter);

Wykorzystanie:
...
.filter(startsWithLetter.apply("N"))

Comparator

Comparator ma domyślne metody: thenComparing(innyComparator) i thenComparing(funkcjaWyciągającaRzeczDoPorównywania, comparator)

W mapie są comparatory comparingByKey i comparingByValue zarówno w naturalnym porzadku jak i z wykorzystaniem przekazanego comparatora
są też comparingInt/Long/Double(przyjmuje ToIntFunction która wyciąga z obiektu typ prosty


Rekurencja ogonowa

Java nie optymalizuje rekurencji ogonowej. Wywołanie metody można przenieść ze stosu na stertę. Kolejne wywołania można przedstawić w postaci obiektu który albo jest potrzebny do kolejnego wywołania albo posiada wynik.

Obiekt może rozróżnić te 2 sytuacje i rzucać wyjątek jeśli jest pobierany wynik z nieukończonej rekurencji lub rekurencja jest wywoływana dla obiektu posiadającego wynik.

Liczba możliwych wywołań rekurencyjnych jest niedeterministyczna. Wątek wykonujący wywołania ściga się z JITem który kompresuje ramkę. Im wcześniej JIT ją skompresuje tym więcej wywołań będzie miało miejsce przed przepełnieniem stosu. W przypadku wyłączenia JITa przez -Djava.compiler=NONE ilość wywołań jest stała i zależna od wielkości stosu.


Memoizer

Funkcja która jest rekurencyjnie wywoływana ma referencje do samej siebie owrapowanej w coś co ją cacheuje.
Memoizer tworzy swoją implementacje Function która ma cache dla parametrów wywołania, jeśli nie ma wyniku w cache to wywołuje przekazaną funkcję ale podając referencje do siebie (wrappera). Przez co funkcja może wykorzystać wrapper wywołując się rekurencyjnie.

class Memoizer {
public static <T,R> R callMemoizer(final BiFunction< Function<T,R>,T,R> function, final T input ){
Function<T,R> memoized = new Function<T,R>(){
HashMap<T,R> store = new HashMap<>();
@Override
public R apply(T input) {
return store.computeIfAbsent(input, key -> function.apply(this, key));
}
};
return memoized.apply(input);
}
}

Nieco podobne do ThreadLocal, nie ma globalnego cache w którym trzeba szukać po wątku / funkcji i argumencie. A każdy wątek/funkcja ma swój cache. Nie sprawdzi się dla rekurencji liniowej (która woła się tylko raz) bo wartości będą dodawane do cache jak już będą niepotrzebne.

Źródła:
„Functional Programming in Java: Harnessing the Power Of Java 8 Lambda Expressions ” Venkat Subramaniam

Dokumentacja Streama

Dokumentacja Collectora

Pola i Effectively final (23 minuta)

Throwing Undeclared Checked Exceptions