# Nemennosť (Immutability)
Nasledujúca kapitola nie je o návrhovom vzore, ale o programovacom princípe, ktorý je natoľko dôležitý, že sme sa mu rozhodli plnohodnotne venovať, nakoľko ho budeme vo veľkom používať vo zvyšku knihy.
Prvá vec po "Hello world", ktorá nám bola každému predstavená, keď sme sa učili programovať, sú premenné. Zistili sme, že je to jedinečný nástroj na tvorbu dynamických programov. Tieto malé škatuľky, ktoré nám boli poskytnuté z pamäti len pre nás, nám dovoľujú hodnoty nastavovať, čítať a dokonca aj modifikovať. Nielenže nám umožňujú zachytiť neustále meniacu sa realitu, ale fungujú rovnako, ako funguje hardvér, v ktorom žijú - počítač nám teda pri ich manipulácii rozumie a funguje rýchlo. Je však naozaj všetko také dobré a ideálne, ako si myslíme?
Ukážme si jednoduchý matematický príklad. Majme 2 premenné a
a b
, a premennú c
, ktorá vyjadruje súčet ich hodnôt. Dosaďme si za a
a b
čísla 5
a 3
a zapíšme to:
a = 5
b = 3
c = a + b
Je nám okamžite jasné, že premenná c
nadobúda v tomto prípade hodnotu 8
. Poďme si ukázať ekvivalentný príklad v Jave:
int a = 5;
int b = 3;
int c = a + b;
Ako by sa dalo čakať, premenná c
bude tiež nadobúdať hodnotu 8
(avšak neberte nás za slovo - pokojne tento príklad vyskúšajte v JShell!). Zatiaľ je všetko v poriadku, no čo by sa stalo, ak by sme v Jave následne prepísali hodnotu premennej c
na niečo iné, napr. 999
? Absolútne nič - hodnota by bola vymenená za novú, presne tak, ako sme sa to učili:
int a = 5;
int b = 3;
int c = a + b;
c = 999; // This is valid in programming language
Ako by sa niečo takéto dalo vyjadriť v matematickom zápise? Mohli by sme skúsiť napr.:
a = 5
b = 8
c = a + b
c = 999 // ??? 8 = 999 ???
No veľmi rýchlo si uvedomíme, že niečo nie je v poriadku - vyšlo by nám, že 8
je rovné 999
! Ako je to však možné? V oboch prípadoch sme hovorili o premenných, tak prečo je v programovacom jazyku možné premennú meniť, no v matematike nie? Musíme si uvedomiť zásadný rozdiel - kým v Jave je možné c
meniť, lebo v skutočnosti len meníme hodnotu v pamäti, ktorú sme si tak pomenovali, v matematike je premenná c
len iné vyjadrenie výrazu a + b
. V momente, kedy hodnoty a
aj b
(nezávislých premenných) určíme, premenná c
(závislá premenná) bude okamžite jasne a nemeniteľne daná. Naopak, v programovacích jazykoch sa len hodnoty v dvoch pamäťových blokoch, ktoré sme si pomenovali a
a b
, sčítajú a uložia do nového pamäťového bloku, ktorý sme pomenovali c
.
Berúc toto do úvahy, zistíme, že sme na začiatku klamali - príklady neboli vôbec ekvivalentné, pretože symbol =
vyjadruje v matematike rovnosť ľavej a pravej strany výrazu, kým v Jave vyjadruje priradenie hodnoty výrazu na pravej strane do premennej na ľavej strane.
Môže sa zdať, že tento rozdiel vieme ignorovať s tým, že "veď si to pamätáme", no, bohužiaľ, problémov je viac. Ukážme si ďalší príklad - množinovú operáciu zjednotenie. Majmä nasledujúci matematický príklad:
A = { 1, 2, 3 }
B = { 2, 3, 4 }
U = A ∪ B
U = { 1, 2, 3, 4 }
V Jave máme pre reprezentáciu množín dátovú štruktúru Set
- konkrétne jeho implementáciu HashSet
. Keďže žiadny operátor zjednotenia neexistuje, musíme použiť metódu addAll
:
Set<Integer> A = new HashSet<>(Arrays.asList(1, 2, 3));
Set<Integer> B = new HashSet<>(Arrays.asList(2, 3, 4));
A.addAll(B);
Na prvý pohľad sa zdá, že všetko je na rozdiel od predchádzajúceho príkladu v poriadku - premenné A
a B
sme neupravili. Napriek tomu, ak vypíšeme hodnoty premennej A
pred a po zjednotení s B
, zistíme, že jej hodnota bola predsalen zmenená:
> System.out.println(A)
[1, 2, 3]
> A.addAll(B)
true
> System.out.println(A)
[1, 2, 3, 4]
Ako je toto možné? Odpoveď je, že metóda addAll
je tzv. deštruktívna - jej zavolaním sa zničí (zmení) vnútorný stav objektu. Opäť je to niečo, čo v matematike nemá obdobu - množina A
je jasné daná, ako by sa potom zjednotením s inou mala jej hodnota akokoľvek meniť? To však nie je všetko - všimnite si, že metóda addAll
má návratovú hodnotu boolean
- true
ak bola množina modifikovaná, false
v opačnom prípade. Zmena samotnej množiny tak bola tzv. vedľajší účinok (side effect). To sa líši od funkcií v matematike - ak by sme chceli operáciu zjednotenia definovať ako funkciu f
, zapísali by sme ju nasledovne:
A, B ⊆ Z
f(A, B) = A ∪ B
Táto funkcia vyprodukuje pre rovnaký vstup vždy rovnaký výsledok a (samozrejme) nevyprodukuje žiadny vedľajší efekt. V programovaní voláme takéto matematické funkcie čisté. Ako sme si všimli, addAll
nespĺňa ani jednu z podmienok - je preto nečistá.
To však, bohužiaľ, nie je koniec. Možno najväčší rozdiel v práci (a rozmýšľaní) matematikov a programátorov je spôsob, akým zapisujú riešenia problémov. Ukázať si to môžeme na jednoduchom príklade - vyjadrenie n-tého Fibonacciho čísla. V matematike by sme ho definovali nasledovne:
n ∈ N
n-té Fibonacciho číslo je číslo k také, pre ktoré platí:
n ≤ 2 => k = 1
n > 2 => k = k(n - 1) + k(n - 2)
V Jave by sme, naopak, napísali:
int fibonacciNumber(int index) {
if (index <= 2) {
return 1;
}
long previousValue = 1;
long finalValue = 2;
for (int i = 3; i < index; i++) {
long newFinalValue = previousValue + finalValue;
previousValue = finalValue;
finalValue = newFinalValue;
}
return finalValue;
}
Všimnime si zásadný rozdiel - kým v matematike sme definovali, čo znamená pojem "n-té Fibonacciho číslo", v (imperatívnom) programovacom jazyku sme zapísali, ako ho vypočítať. Samozrejme, algoritmus vyššie môžeme prepísať na rekurzívny, no v Jave by sme veľmi rýchlo zistili, že by bol extrémne pomalý a fungoval by len pre malé indexy. Tento rozdiel sa dá popísať ešte inak - matematický zápis (rekurzívny algoritmus) je bezstavový (stateless), kým Javouský cyklus je stavový (stateful).
Na zhrnutie, v procedurálnych (C, Pascal) a objektovo orientovaných jazykoch (Java, C#) sme zvyknutý písať kód, ktorý je:
- menný - používa premenné, ktorých hodnotu mení aj po ich prvotnom nastavení
- nečistý - obsahuje množstvo funkcií (metód) s vedľajším efektom
- stavový - používa stavové konštrukcie (napr. cykly) pre výpočet
Naproti tomu, matematici sú zvyknutý na zápisy, ktoré sú:
- nemenné - ak je raz premennej určená hodnota, tás hodnota jej aj ostane
- čisté - všetky funkcie sú bez vedľajších efektov a pre rovnaký vstup vrátia rovnakú hodnotu
- bezstavové - nikde v matematike neudržiavame aktuálny stav, ktorý meníme; častokrát používame rekurziu na vyjadrenie napr. postupností
Jazyky, ktoré stavajú na týchto myšlienkach, voláme funkcionálne jazyky. Príkladmi sú Haskell (čisté funkcionálne programovanie) a Scala (ktorá je multiparadigmická - spája funkcionálne a objektovo orientované programovanie). V imperatívnych programovacích jazykoch, napr. v Jave, kde sa postupne pridali prvky z funkcionálnych jazykov, hovoríme o programovaní vo funkcionálnom štýle.
Prirodzene sa nám naskytá otázka, prečo sa ideme venovať princípom z funkcionálnych jazykov, keď Java je objektovo orientovaná? Dôvody sú hneď 2 - estetický a praktický.
Z estetického hľadiska, teda z pohľadu čitateľnosti kódu, sme si ukázali, že klasický matematický prístup k popisu riešenia problémov je konzistentnejší, bez skrytých "prekvapení" a prehľadnejší. Nemusíme sa trápiť tým, ako musíme výpočet vykonať, trápime sa tým, ako ho definovať, ako ho opísať. Takto píšeme kód, ktorý je zrozumiteľnejší a lepšie udržiavateľný. A to ocenia nielen veľké tímy, ale aj individuálni programátori, ktorí sa ku svojmu kódu vracajú po určitom čase.
Praktické výhody spočívajú najmä v bezpečnosti a testovateľnosti kódu. Keď máme nemenné triedy, nemusí nás trápiť, čo sa bude s inštanciou diať - nemusíme zbytočne vytvárať defenzívne kópie po celom kóde. Používaním a písaním čistých metód robíme svoj kód podstate viac a jednoduchšie testovateľný. Zároveň (vďaka nemennosti) dostávame skrytú výhodu - kód je zároveň bezpečný pre viacvláknové programovanie. A to je obrovská výhoda v čase, kedy je trendom zvyšovanie počtu jadier oproti výkonu jedného.
Týchto konceptov sa však netreba báť, práve naopak, už ste sa s nimi stretli. Trieda reťazca String
je nemenná - všimnite si, že žiadna operácia (vrátane +
) nezmodifikuje originálny reťazec, ale vytvorí nový. Podobne je na tom nová knižnica pre dátumy a čas Date/Time API
, ktorá bola do Javy pridaná vo verzii 8.
V rámci tejto kapitoly si ukážeme, ako uplatniť nemmenosť (immutability) v našom kóde a ako implementovať čisté metódy. Vzhľadom na limitáciu Javy (resp. absenciu niektorých vlastností) si nemôžeme ukázať napr. využívanie rekurzie v našich algoritmoch. Ak by ste mali záujem, ako sa v takomto štýle programuje, odporúčame knihu Programming in Scala od Martina Oderskeho a kolektívu.
Poznámka
Kompilátory funkcionálnych programovacích jazykov sú postavené tak, aby funkcionálne zápisy optimalizovali pre počítače. Napr. rekurzívne funkcie, ktoré sa končia volaním samých seba (tzv. tail recursion), sú prepísané na obyčajný cyklus. Myšlienka je však v tom, že funkcionálny zápis je deskriptívny a teda je ľahšie porozumiteľný ako jeho imperatívny zápis v cykle, ktorý je vhodný pre samotný počítač. Pamätajme - píšeme programy pre ľudí, nie pre stroje!
# Modelová situácia
Keďže sme spomínali, že nemennosť ide ruka v ruke s matematikou, ukážeme si ju v praxi na implementácii racionálneho čísla. Tento príklad je inšpirovaný z už vyššie spomínanej knihy Programming in Scala od Martina Oderskeho a kolektívu.
Racionálne číslo môžeme definovať ako pomer A / B
, kde A
je z množiny celých čisel a B
je z množiny prirodzených čísel. A
označujeme ako čitateľ (angl. numerator) a B
ako menovateľ (angl. denominator). Na základe toho môžeme vytvoriť jednoduchú implementáciu:
public class Rational {
private int numerator;
private int denominator;
public Rational(int numerator, int denominator) {
this.numerator = numerator;
this.denominator = denominator;
}
public int getNumerator() {
return numerator;
}
public int getDenominator() {
return denominator;
}
}
Tak, ako sme v úvode spomínali, že číslo päť
vždy bude mať hodnotu 5
, aj po vypočítaní príkladu 5 + 3
, jedna tretina
ostatne taktiež vždy 1/3
, aj po vypočítaní napr. (1/3) * 2
. Preto nedáva zmysel, aby sme nechávali atribúty numerator
a denominator
ako premenné, ktorých hodnota sa môže v čase meniť. Aby sme docielili, že ich bude možné nastaviť len počas vytvárania objektu, označíme obe (zatiaľ) premenné kľúčovým slovom final
:
public class Rational {
private final int numerator;
private final int denominator;
// Rest ommited for brevity
}
Takýto zápis má za následok, že kompilátor bude hlásiť chybu:
- ak by sme sa tieto hodnoty snažili po priradení zmeniť
- ak by sme tieto hodnoty nepriradili vôbec
To nám efektívne zaručí, že atribútom numerator
a denominator
musia byť hodnoty priradené práve raz, a to v konštruktore počas vytvárania inštancie triedy.
Poznámka:
Iné jazyky môžu nemennosť atribútov riešiť po svojom. C# používa kľúčové slovo readonly
ako ekvivalent Javouského final
. Scala a Kotlin majú zápis val attributeName
, kde val
označuje value
(ako kontrast pre var
, čo označuje variable
).
V JShell si teraz môžeme vytvoriť niekoľko ukážok:
> Rational oneThird = new Rational(1, 3);
Defined field Rational oneThird = sk.tuke.fei.kpi.oop.chapters.structural.immutability.Rational@589838eb
> Rational eightFifths = new Rational(8, 5);
Defined field Rational eightFifths = sk.tuke.fei.kpi.oop.chapters.structural.immutability.Rational@6500df86
Vidíme však, že výpis sk.tuke.fei.kpi.oop.chapters.structural.immutability.Rational@589838eb
nám nič nehovorí. Aby sme to v rýchlosti "opravili", v triede Rational
prepíšeme metódu toString
, ktorá vracia čitateľnú reprezentáciu objektu. V praxi si ju viac ukážeme v ďalšej kapitole, nateraz sa uspokojíme len s výsledkom. Po pridaní nasledujúcej metódy do triedy:
@Override
public String toString() {
return numerator + " / " + denominator;
}
nám už výpis v konzole bude dávať zmysel:
> Rational oneThird = new Rational(1, 3);
Defined field Rational oneThird = 1 / 3
> Rational eightFifths = new Rational(8, 5);
Defined field Rational eightFifths = 8 / 5
Vráťme sa na chvíľu k rozdielom medzi matematikou a Javou. Ak máme 2 rôzne tretiny, vieme, že sú obe totožné, preto môžeme napísať nasledovnú rovnosť.
1/3 = 1/3
V Jave, keď chceme porovnávať hodnoty dvoch premenných, používame metódu equals
. Ak ju však použijeme na 2 rôzne tretiny:
Rational oneThird = new Rational(1, 3);
Rational anotherOneThird = new Rational(1, 3);
oneThird.equals(anotherOneThird);
Zistíme, že výraz nám vyhodnotí ako false
. Ako je to možné? Pretože Java nevie, kedy sú 2 objekty triedy Rational
zhodné. Preto ju to musíme naučiť prepísaním metódy equals
a definovaním rovnosti:
@Override
public boolean equals(Object obj) {
// Check if given object is an instance of this class
if (!(obj instanceof Rational)) {
return false;
}
// Compare numerator and denominator of both rationals
Rational other = (Rational) obj;
return denominator == other.denominator
&& numerator == other.numerator;
}
Teraz nám už porovnanie tvoj rôznych inštancií jednej tretiny vráti pravdivú hodnotu true
. Viac sa o porovnávaní objektov dozviete v nasledujúcej kapitole.
Než sa presunieme na podrobnejšiu implementáciu triedy, uvažujme ešte, ako by sme konvertovali celé čísla z primitívneho typu int
do našej triedy pre racionálne čísla. Celé čísla nie sú nič iné, ako čitatele s "neviditeľným" menovateľom 1
. Preto napr. číslo 5
vieme zapísať ako racionálne číslo v tvare new Rational(5, 1)
. Tŕňom v oku ostáva jednotka - veď predsa nikdy nepíšeme celé čísla v tvare zlomku! Aby sme zlepšili klientský kód, implementujeme ďalší konštruktor, ktorý však bude mať len 1 parameter:
public Rational(int number) {
this.numerator = number;
this.denominator = 1;
}
Teraz by sa vám mala rozsvietiť varovná kontrolka DRY princípu - oba konštruktory vyzerajú prakticky rovnako - oplatí sa nám duplikovať kód len kvôli krajšiemu zápisu? Našťastie, Java na nás myslela a dáva nám spôsob, ako interne volať kód iného konštruktora - kľúčové slovo this
bude volať v konštruktore ako metódu s príslušnými parametrami - v tomto prípade this(number, 1)
. Po úprave budú naše konštruktory vyzerať nasledovne:
public Rational(int numerator, int denominator) {
this.numerator = numerator;
this.denominator = denominator;
}
public Rational(int number) {
this(number, 1);
}
Odteraz je zápis new Rational(5)
validný a slúži ako alias zápisu new Rational(5, 1)
. Ďalšia podobná vec, ktorú môžeme interne ošetriť, je zjednodušenie zlomku. Okrem estetickej vlastnosti to má výhodu v tom, že na rozdiel od matematiky môžeme v dátovom type int
držať len hodnoty do určitej veľkosti - preto zjednodušením zlomku nebudeme týmto cenným priestorom plýtvať.
Ako teda na to? Najprv musíme vypočítať najväčší spoločný deliteľ, ktorým následne čitateľ a menovateľ predelíme. Vytvorme si teda najprv privátnu metódu, ktorá nám tohto deliteľa nájde. Využijeme pri tom Euclidov algoritmus:
private int calculateGCD(int a, int b) {
int dividend = Math.max(a, b);
int divisor = Math.min(a, b);
while (divisor > 0) {
int remainder = dividend % divisor;
dividend = divisor;
divisor = remainder;
}
return dividend;
}
Túto hodnotu následne vypočítame do dočasnej premennej, ktorou už len predelíme čitateľa a menovateľa. Všimnime si, že pri výpočte najväčšieho spoločného deliteľa sme použili absolútnu hodnotu čitateľa - to preto, lebo euklidov algoritmus funguje len pre kladné čísla.
public Rational(int numerator, int denominator) {
int gcd = calculateGCD(Math.abs(numerator), denominator);
this.numerator = numerator / gcd;
this.denominator = denominator / gcd;
}
O správnosti fungovania kódu sa môžeme presvedčiť nasledujúcim príkladom:
> Rational a = new Rational(12, 8);
Defined field Rational a = 3 / 2
> Rational b = new Rational(9, 21);
Defined field Rational b = 3 / 7
Ostáva nám posledná vec ohľadom atribútov racionálneho čísla - ich definičné obory. Z definície, ktorú sme napísali vyššie, nám vyplýva, že čitateľ numerator
nemusíme nijako kontrolovať, no aktuálne je možné nastaviť menovateľa denominator
na záporné čísla, či dokonca nulu, čo v žiadnom prípade nie je platné racionálne číslo. Aby sme takéto situácie ošetrili, musíme v hlavnom konštruktore skontrolovať hodnotu menovateľa a v prípade neplatnej hodnoty vyhodiť chybu, aby klientský kód vedel, že robí niečo zlé. Java nám na také situácie ponúka výnimku IllegalArgumentException
, ktorú použijeme nasledovne:
public Rational(int numerator, int denominator) {
if (denominator < 1) {
throw new IllegalArgumentException("Denominator must be a natural number!");
}
int gcd = calculateGCD(Math.abs(numerator), denominator);
this.numerator = numerator / gcd;
this.denominator = denominator / gcd;
}
Výborne - každá inštancia triedy Rational
nám teraz reprezentuje nemenné a validné racionálne číslo. Aktuálne je nám však trošku zbytočná - veď nevieme nad ňou robiť žiadne operácie! Poďme sa pozrieť na to, ako implementovať operáciu sčítania.
Ak by sme mohli modifikovať vnútorné premenné, možno by nás lákalo urobiť niečo nasledovné:
public void add(Rational other) {
this.numerator = numerator * other.denominator + other.numerator * denominator;
this.denominator = denominator * other.denominator;
}
My však už vieme, že zlomok svoju hodnotu meniť nemôže, preto výsledok (návratová hodnota) operácie sčítania musí byť nová inštancia triedy Rational
. Preto metódu add
prepíšeme nasledovne:
public Rational add(Rational other) {
return new Rational(
numerator * other.denominator + other.numerator * denominator,
denominator * other.denominator
);
}
Metóda add
je teraz čistá - pre rovnaký vstup vypíše vráti rovnaký výstup a zároveň nemá žiadne vedľajšie efekty!
Čo však, ak by sme chceli k zlomku pripočítať celé číslo? Vďaka nášmu alternativnému konštruktoru by sme súčet 1/3 + 5
vedeli zapísať ako oneThird.add(new Rational(5))
. Vyzerá to ale trošku neprehľadne - namiesto jedného znaku pre číslo päť sme ich použili 15. Preto preťažíme pôvodnú implementáciu metódy add
novou, ktorá akceptuje ako parameter dátový typ int
:
public Rational add(int number) {
return new Rational(
numerator + number * denominator,
denominator
);
}
Interne sa nám takto dvakrát opakuje prakticky ten istý kód. V zmysle DRY princípu tak môžeme výpočet delegovať pôvodnej metóde. Inými slovami - vytvoríme alias:
public Rational add(int number) {
return add(new Rational(number));
}
Majte však na mysli, že takto vytvárame novú inštanciu objektu, ktorá bude okamžite po dokončení metódy stratená. V istých prípadoch to môže byť zbytočné - pokojne by sme mohli nechať pôvodnú implementáciu, ktorá sčítava zlomok s celým číslom priamo.
# Best practices
- Automaticky označujte lokálne a globálne premenné modifikátorom
final
, aby sa stali nemennými - Modifikátorom
final
môžete pokojne označiť aj parametre metódy - takto zaručíte, že ich hodnoty ani náhodou neprepíšete - Ak máte pocit, že potrebujete pracovať s premennou, ktorej hodnotu treba v čase meniť (teda je nutne udržiavať informáciu o nejakom stav), skúste najprv porozmýšľať, či sa problém nedá vyriešiť bezstavovo a nemenne
- Ak odpoveď na otázku vyššie je nie, resp. potrebujete menné premenné kvôli výkonu aplikácie, odstráňte modifikátor
final
z príslušných premenných
# Úlohy
# 1. Doplňte ďalšie operácie na triede Rational
V modelovej situácii sme ukázali, ako môžeme implementovať sčítanie dvoch racionálnych čísel. Teraz je rad na vás, aby ste si to vyskúšali sami a rozšírili triedu Rational
o nasledujúce metódy:
public double toDouble();
public Rational negate();
public Rational sub(Rational other);
public Rational sub(int other);
public Rational mul(Rational other);
public Rational mul(int other);
public Rational div(Rational other);
public Rational div(int other);
Vysvetlivky:
toDouble
nám konvertuje zlomok na desatiné číslonegate
nám vráti opačnú hodnotusub
vykoná operáciu odčítania sRational
aleboint
mul
vykoná operáciu násobenia sRational
aleboint
div
vykoná operáciu delenia sRational
aleboint
Snažte sa neduplikovať kód, ale šikovne využívať už existujúce metódy. Inšpirujte sa príkladom - operácia odčítania (a - b
) je to isté, ako pripočítanie opačnej hodnoty (a + (-b)
).
# 2. Vytvorte objektovú reprezentáciu matematickej funkcie
Keď sa už toľko oháňame s matematikou, poďme sa zamerať na jednu zo základných nástrojov tejto vedy - funkcie. Konkrétne si ukážeme funkcie v oboru reálnych čísel - čo v Jave znamená dátový typ double
. Autori jazyka na nás mysleli a do štandardnej knižnice implementovali rozhranie Function
. Tej však chýbajú mnohé operácie ako napr. sčítanie 2 funkcií, preto sme sa rozhodli, že ju obalíme vlastnou implementáciou, ktorá tieto vlastnosti pridá.
Vytvorte triedu Func
, ktorá bude pozostávať z vnútornej Function
funkcie. To by vám malo umožniť vytvárať triedu vo funkcionálnom štýle, napr. kvadratickú funkciu by ste vedeli zapísať ako new Func(x -> x * x)
. Zabezpečte, aby vaša implementácia mala nasledujúce metódy:
apply
, ktorá zavolá vnútornúFunction.apply
a vráti výsledokadd
, ktorá vráti funkciu rovnú súčtu vnútornej a poskytnutej funkciesub
, ktorá vráti funkciu rovnú rozdielu vnútornej a poskytnutej funkciemul
, ktorá vráti funkciu rovnú násobku vnútornej a poskytnutej funkciecompose
, ktorá vráti funkciu rovnú zloženiu vnútornej s vonkajšiou (rovnaká implementácia akoFunction.compose
)negate
, ktorá vráti funkciu vracajúcu opačnú hodnotu vypočítanej vnútornou funkciou
Zároveň preťažte metódy, aby prijímali Func
, Function
aj double
ako svoj argument.
Svoju implementáciu si môžete vyskúšať napr. nasledovným spôsobom:
Func f = new Func(x -> x * x);
Func g = new Func(Function.identity()).mul(2);
// h(x) = x^2 + 2*x - 5
Func h = f.add(g).sub(5);
// 30
h.apply(5);
// -2
h.apply(1);
// 3
h.apply(-4);
# 3. Definujte štandardné funkcie ako konštanty pre pohodlie programátorov
Ak by sme začali písať kód, veľmi rýchlo by sme zistili, že niektoré štandardné funkcie sa nám budú opakovať, napr. kvadratická či lineárna. Okrem porušenia DRY princípu tu smrdí aj čitateľnosť kódu - význam new Func(x -> x * x)
je síce jasný, ale zápis Func.QUADRATIC
je predsalen krajší.
Vytvorte statické premenné v triede Func
pre štandardné a často použivané funkcie (aspoň pre elementárne). Nezabudnite ich označiť public static final
a ich mená napísať veľkými písmenami. Zároveň, kde je to možné, využite existujúce implementácie niektorých funkcií v Javouskej triede Math
.
# 4. Konvertujte objekt matematickej funkcie na funkcionálne rozhranie
Všimnime si zápis vytvárania novej inštancie triedy Func
:
Func f = new Func(x -> x + 2);
Takýto zápis je možný vďaka tomu, že konštruktor akceptuje Javouské funkcionálne rozhranie Function
. Keby sme chceli vytvoriť separátnu inštanciu tejto vnútornej reprezentácie funkcie, vyzeralo by to takto:
Function<Double, Double> f = x -> x + 2;
Bližšie sa už k matematickej reprezentácii nedostaneme - je to určite lepšie, ako naše obalenie tohto rozhrania. Našťastie, niečo podobné vieme využiť aj my - ako sme už spomenuli v paragrafe vyššie, musíme vytvoriť naše vlastné funkcionálne rozhranie. A to je podstatou tejto úlohy.
Vytvorte funkcionálne rozhranie Fun
, ktoré bude mať všetky vlastnosti ako trieda Func
. V nej implementujte 1 abstraktnú metódu apply
, ktorá prijme a vráti výsledok v dátovom type double
. Všetky ďalšie metódy pre kombináciu funkcii implementujte s modifikátorom default
- to vám umožní zachovať rozhranie funkcionálne. Na záver pridajte štandardné funkcie nie ako konštanty, ktoré sa v rozhraní vytvoriť nedajú, ale ako statické metódy (a zmeňte názvy tak, aby ste dodržali konvencie pre pomenúvavanie metód). Ak neviete, kde máte začať, môžete použiť túto ukážku:
@FunctionalInterface
public interface Fun {
// abstract `apply` method is missing - add it!
default Fun add(Fun other) {
return x -> apply(x) + other.apply(x);
}
static Fun cubic() {
return x -> x * x * x;
}
}
Poznámka
Toto rozhranie môžete označiť anotáciou FunctionalInterface
, ktoré vám vyhodí kompilačnú chybu, ak budete mať viac ako 1 abstraktnú metódu.
Svoju implementáciu si môžete vyskúšať napr. nasledovným spôsobom:
Fun f = x -> x*x + 5*x;
Fun g = Fun.exponential();
// h(x) = e^( 5*x^2 + 25*x )
Fun h = g.compose(f.mul(5));
// 1
h.apply(0);
// ~ 708.04
h.apply(0.25);
// ~ 936589.2
h.apply(0.5);
# Riešenia úloh
Nasledujúce kódy obsahujú riešenia zo všetkých úloh.