BlogCargo culty v Jave: funkcionálne programovanie

Cargo culty v Jave: funkcionálne programovanie

Cargo culty v Jave: funkcionálne programovanie 2

Predošlý blog vysvetľuje, čo je to „cargo cult“ a ako sa prejavuje pri objektovo-orientovanom programovaní v Jave. Pojem „cargo cult“ pochádza z tichomorských ostrovov, na ktorých domorodci napodobňujú počíňanie amerických vojenských jednotiek vo viere, že im z neba príde náklad s tovarom (cargo). V niektorých situáciách si presne takto počíňajú programátori: napodobňujú best practices bez toho, aby im rozumeli.

Cargo cult funkcionálneho programovania

„V Jave sa nikdy nepoužíva for-cyklus na prechádzanie zoznamu (Collection). Namiesto for-cyklu sa vždy musia použiť stream-y.“

Takto formulovaná zásada je nesprávna. Podpora pre funkcionálne programovanie v Jave neznamená, že for-cyklus je minulosťou.

Programátorské paradigmy

Pre správne používanie funkcionálneho programovania v Jave je nutné pochopiť rozdiel medzi funkcionálnym a imperatívnym programovaním. Jazyk Java podporuje imperatívne, funkcionálne a objektovo-orientované programovanie. Tieto paradigmy sa dajú charakterizovať podľa spôsobu dekompozície, t.j. rozkladu zložitého problému na jednoduchšie celky:

Paradigma

Dekompozícia

Imperatívna

Dekompozícia cez samostatne spustiteľné časti kódu – procedúry.

Funkcionálna

Dekompozícia cez zjednodušovanie až po úroveň atomických výrazov.

Objektovo-orientovaná

Dekompozícia podľa zodpovednosti na samostatné balíky dát a operácií nad nimi – objekty.

Viacero cargo-cultov ohľadom funkcionálneho programovania vychádza z jeho formálneho uchopenia. Programátor zvládne funkcionálnu syntax v súvislosti so streamami – a akonáhle uchopí toto kladivo, každý problém vyzerá ako klinec.

Funkcionálna paradigma

Funkcionálne programovanie vychádza z funkcionálnej analýzy. Základom je rozmýšľať v matematických funkciách. Matematická funkcia je priradenie, v ktorom každému prvku z definičného oboru priraďujeme prvok z oboru hodnôt.

Podstatnou vlastnosťou funkcie je, že:

  1. Nemení definičný obor. Programátorsky to znamená, že vstupné parametre do funkcie nesmú byť zmenené („side-effect free“, resp. „pure function“).
  2. Má jasne definovaný vstup aj výstup.

Programátorsky sa dá veľa vyťažiť z toho, keď funkcie operujú nad rovnakým definičným oborom a oborom hodnôt – napríklad aritmetické funkcie majú ako vstup aj výstup celé číslo. Umožňuje to skladať funkcie a teda bočným efektom je re-use kódu.

Používanie forEach

Jedným z častých formálnych prejavov cargo-cultu pri funkcionálnom programovaní je používanie príkazu forEach. Napríklad zaregistrovanie osoby – t.j. volanie Person.registerOn – je možné implementovať pomocou forEach metódy:

LocalDateTime registrationTime = LocalDateTime.now();
persons.forEach( person -> person.registerOn(registrationTime));

Trieda Person obsahuje atribúty ako meno, priezvisko a dátum narodenia osoby. Obsahuje aj dátum registrácie osoby (na stránku, do knižnice, do systému apod.). Volaním metódy „registerOn“ sa osoba zaregistruje, t.j. zapamätá si dátum registrácie:

public final class Person {
	
	private String givenName;
	private String familyName;
	private LocalDate birthDate;
	// TODO: constructor with these three attributes
	
	private LocalDateTime registration;
	
	public void registerOn( LocalDateTime registrationTime ) {
		registration = registrationTime;
	}

	// TODO: get methods
}

Cieľom volania persons.forEach je dosiahnuť, aby sa všetky osoby zo zoznamu persons zaregistrovali na aktuálny dátum a čas. Na ten účel volá metódu registerOn.

Takéto použitie funkcionálneho zápisu nespĺňa kľúčové kritérium funkcionálneho programovania: nemeniť prvky z definičného oboru. Po vykonaní príkazu sú vstupné dáta zmenené – majú vyplnený atribút „registration“.

Alternatíva 2 – forEach

Východiskom nie je ani použitie forEach zo streamov, pretože trpí rovnakým neduhom – mení vstupné dáta:

Cargo culty v Jave: funkcionálne programovanie 4
Cargo culty v Jave: funkcionálne programovanie 6
LocalDateTime now = LocalDateTime.now();
persons.stream().forEach( person -> person.registerOn(now) );

Problém nie je volanie metódy forEach, ale zmena vstupných hodnôt.

Alternatíva 3 – forEach

Prípadný trik s vytvorením „PersonUtils“, ktorý registráciu vykoná, je opäť iba formálna zmena a takisto predstavuje nevhodnú kombináciu funkcionálneho a objektovo-orientovaného prístupu:

persons.stream().forEach( PersonUtils::registerOn );

Riešenie 1: for-cyklus

Na zmenu hodnôt objektov nie je vhodné používať funkcionálny zápis. Adekvátne je v tomto prípade použiť tradičné imperatívne programovanie:

LocalDateTime registrationTime = LocalDateTime.now();
for (Person person : persons) {
	person.registerOn(registrationTime);
}

Riešenie 2: funkcionálne programovanie

Alternatíva voči objektovo-orientovanému programovaniu je zachovanie paradigmy funkcionálneho programovania. Vo funkcionálnom programovaní sa zmena atribútu rieši vytvorením nového objekt typu Person. Čiže metóda registerOn  v triede Person je implementovaná takto:

public Person registerOn( LocalDateTime registrationTime ) {
	Person newPerson = new Person( this.givenName, this.familyName, this.birthDate );
	newPerson.registration = registrationTime;
	return newPerson;
}

Potom je však aj volanie registrácie iné:

newPersons = persons.stream()
		.map( person -> person.registerOn(registrationTime) )
		.collect(Collectors.toList() );

Z pôvodného zoznamu persons vznikne nový zoznam newPersons s novými objektami typu Person. Prvky definičného oboru sa nemenia a teda funkcia nemá bočné efekty („side-effect free“). Takýto prístup nie je vždy možné použiť – treba napríklad zvážiť dopady v situácii kedy je objekt Person mapovaný na databázovú tabuľku.

Zhodnotenie alternatív

Hoci pôvodný zápis volaním persons.forEach metódy vyzerá jednoducho a elegantne, v skutočnosti nevhodným spôsobom kombinuje syntax funkcionálneho programovania s objektovo-orientovanou paradigmou. O nevhodnosti používania forEach operácie píše aj Joshua Bloch v knihe „Effective Java – Third Edition“:

„The forEach operation should be used only to report the result of a stream computation, not to perform the computation.“

Volanie forEach treba používať len v kombinácii s výstupom (System.out apod.), keď sa nemenia vstupné hodnoty.

Kombinácia imperatívneho a funkcionálneho programovania

Prejavom násilného používania funkcionálneho programovania je nevhodné používanie anonymných implementácií funkcionálnych rozhraní:

persons.stream().map( person -> {
	LocalDate legalYear = LocalDate.now().minusYears(18);			
	if ( person.getBirthDate().isAfter(legalYear) ) {
		// not legal to register
		return false;
	} else {
		LocalDateTime now = LocalDateTime.now();
		person.registerOn(now);
		return true;
	}
	}	)
	.collect(Collectors.toList() );

Použitie funkcionálneho zápisu musí ísť ruka v ruke s použitím funkcionálneho prístupu. Uvádzaný príklad obsahuje nevhodnú kombináciu funkcionálneho a imperatívneho prístupu. V takom prípade treba zvážiť jednu z možností:

  1. Situácia nie je vhodná na implementáciu pomocou funkcionálneho programovania. Jednoduchší a prehľadnejší zápis je pomocou imperatívneho programovania a bežného for-cyklu.
  2. Príklad treba vhodne dekomponovať na volanie funkcií. Podmienky „if“ nahradzovať predikátmi, transformácie používaním „map“ atď.
  3. Celý funkčný blok deklarovať ako samostatnú triedu, ktorá implementuje rozhranie Function (alebo Predicate).

Bod 3 na prvý pohľad vyzerá len ako syntaktické oklamanie problému. V praxi však často nastávajú situácie, kedy funkcia reprezentuje dôležitý pojem z domény – napr. „neplnoletá osoba“. Implementácia funkcie teda môže byť netriviálna – zahŕňať vyhľadávanie v databáze, volanie API iného modulu apod.

Funkcionálne programovanie nie je iba o streamoch

Posledným prejavom cargo-cultu v súvislosti s funkcionálnym programovaním je jeho obmedzenie na použitie v streamoch. Funkcionálne programovanie poskytuje možnosti skladania funkcií, čo je zaujímavé najmä pri používaní predikátov. Pomocou spojení „and“, „or“ a „negate“ je možné aplikovať podmienky aj bez toho, aby programátor využíval streamy:

if ( PersonPredicates.HAS_LEGAL_CAPACITY.negate()
		.and( PersonPredicates.HAS_CONTRACT )
		.test(person) ) {
	// not legal and has contract???
}

Predikát HAS_LEGAL_CAPACITY vyhodnocuje, či je osoba spôsobilá vykonávať právne úkony. Predikát HAS_CONTRACT vyhodnocuje, či má daná osoba uzatvorenú zmluvu. Podmienka „if“ teda zisťuje, či má daná osoba uzatvorenú zmluvu, hoci nemá právnu spôsobilosť.

Oba predikáty reprezentujú dôležité pojmy v doméne a preto je vhodné mať aj v zdrojovom kóde tieto pojmy explicitne deklarované – čiže nie len ako sadu logických podmienok. V prípade oboch predikátov je však na mieste otázka, či by priamo trieda Person nemala mať napríklad metódu

public boolean hasLegalCapacity()

Odpoveď na otázku nie je jednoznačná a súvisí s uplatňovaním „Single responsibility principle“. Pokiaľ by spôsobilosť vykonávať právne úkony súvisela iba s vekom osoby (do 18 rokov), je celkom adekvátne vytvoriť takúto metódu v triede Person. Pokiaľ však vyhodnotenie spôsobilosti znamená zisťovať napr. aj rozhodnutia súdu, nie je dobré preťažovať triedu Person takouto zodpovednosťou. Komplikované vyhodnocovanie  právnej spôsobilosti si môže vyžiadať dokonca vytvorenie samostatnej triedy, ktorá implementuje rozhranie Predicate<Person>.

Účelom príkladu bolo demonštrovať, že používanie funkcionálneho programovania má zmysel aj mimo používania streamov. Čitateľnosti v tomto prípade pomáha explicitné pomenovanie predikátov. V príklade by zrejme systémová analýza obsahovala vetu „ak osoba nie je spôsobilá na právne úkony a zároveň má vytvorenú zmluvu, potom …“. Zdrojový kód veľmi presne kopíruje analýzu.

Záver

Funkcionálne programovanie je užitočná paradigma, ktorá napomáha čitateľnosti kódu. Častokrát je oveľa prirodzenejšie a to najmä pri definícii pravidiel. Treba však rešpektovať jeho obmedzenia a predpoklady.

Základom rozumného používania funkcionálneho programovania je formulovanie riešenia problému jazykom matematických funkcií. Definície funkcií nemusia byť používané výlučne len v kombinácii so streamom.

Pokiaľ sa programátori naučia pracovať s funkcionálnym jazykom len na základe syntaxe, môžu sa stať ľahko obeťou niektorého z cargo-cultu

Dobrý článok? Chceš dostávať ďalšie?

Už viac ako 6 200 ITečkárov dostáva správy e-mailom. Nemusíš sa báť, nie každé ráno. Len občasne.

Súhlasím so spracovaním mojich osobných údajov. ( Viac informácií. )

Tvoj email neposkytneme 3tím stranám. Posielame naňho len informácie z robime.it. Kedykoľvek sa môžeš odhlásiť.

Zdeno Jašek
Zdeno Jašek
Pracujem ako Solution Architect vo firme PosAm a programovaním sa zaoberám takmer 30 rokov. Prešiel som jazykmi Basic, Assembler, Pascal, Object Pascal, Lisp, Prolog, Magic, MUMPS, Clipper, Paradox a Java, z ktorých najmilšia mi je Java. Pracoval som hlavne ako softvérový architekt, ale aj ako programátor, analytik, dizajnér a projektový manažér. Pri vývoji softvéru sa mi najviac páči navrhovanie objektového dizajnu – obzvlášť pre zložité aplikácie. Svoje blogy chcem zamerať na postupy pri vytváraní objektového návrhu aplikácie a ich technologickej realizácii v podobe hexagonálnej architektúry a microservices.

Čítaj ďalej: