O programování 06 - Návrhové vzory - síla i slabina Javy
Pro účely otestování různých implementací cyklu foreach v klasické i funkcionální podobě jsem implementoval čtyři varianty téhož algoritmu, každý jako samostatný program, viz předchozí díly. Tyto čtyři programy dělaly samozřejmě úplně to stejné a množství společného kódu bylo značné, ve skutečnosti se lišily pouze třemi řádky kódu, totiž vlastním cyklem. Inicializace, příprava měření času, výpočet času a jeho výpisy, to vše bylo vždy úplně stejné. Je jasné, že jsem nenapsal čtyři rozdílné programy metodou Ctrl-C/Ctrl-V, ale použil jsem dědičnost a návrhový vzor strategy, resp. to, co si myslím, že je strategy (kdysi jsem měl totiž učenou rozpravu s kolegou, který tvrdil, že moje chápání strategy není strategy podle GOF).
Každopádně nejde o název ale o princip a ten spočívá v tom, že mám abstraktní třídu, která obsahuje všechny společné části - v tomto případě přípravné a měřící. Neobsahuje však žádnou konkrétní implementaci a místo ní se volá abstraktní metoda. Potomci této třídy implementují pouze tuto metodu, takže se jednotlivé implementace liší tím, co mají opravdu odlišné a společný kód je jednotný a společný.
Tento návrhový vzor se používá pro eliminaci ifů, které v OOP nemají co dělat, alespoň podle OOP puristů typu dr. Kravála z objects.cz. Toto tvrzení je sice poněkud silné, protože nelze nahradit všechny ify, ale když se nad tím člověk zamyslí, tak si uvědomí, že existují (minimálně) dva typy ifů. První je spojený s algoritmem jako takovým, např. s jeho hraničními podmínkami. Druhý typ ifů je "strukturální" ve smysl výběru vhodného algoritmu nebo jeho varianty a právě o eliminaci těchto "strukturálních" podmínek je návrhový vzor strategy, aspoň jak ho chápu já.
Síla strategy vynikne v případě, kdy konkrétní implementační třída neimplementuje jen jednu metodu, ale více logicky svázaných metod. Bez použití strategy je kód plný ifů a switchů a často je problém zajistit, aby všechny varianty byly na všech místech správně vyhodnoceny a hlavně, aby se někde na nějakou variantu nezapomnělo. Další problém nastane při přidání nové varianty, kdy se musí všechny podmínky projít a doplnit o novou variantu. V případě strategy je vše potřebné v jedné třídě a přidání nové varianty spočívá v implementaci nové třídy a všech jejich abstraktních metod. Nevýhodou je horší čitelnost kódu, kdy se často z algoritmu "odskakuje" do konkrétních implementací, ale při správném pojmenování to není velký problém.
Nicméně vraťme se od teorie návrhových vzorů ke konkrétní implementaci. Abstraktní třída LoopRunner obsahuje metodu run(), která inicializuje prostředí a v cyklu volá metodu testedMethod(), ta počítá časy pro konkrétní implementace. Vlastní testovaný algoritmus je v abstraktní metodě _testedMethod(), resp. v jejích implementacích u jednotlivých potomků.
public abstract class AbstractLoopRunner {
final int EXEC_COUNT = 10;
final int WARMUP_COUNT = 10;
final int SIZE = 100000;
private final String name;
public AbstractLoopRunner(String name) {
this.name = name;
}
public void run() {
final List<Integer> inputList = new ArrayList<>(SIZE);
for (int i = 0; i < SIZE; i++) {
inputList.add(i);
}
//Warmup
Long warmUpActual;
for (int i = 0; i < WARMUP_COUNT; i++) {
warmUpActual = testedMethod(inputList);
System.out.println(String.format("%d: %d ", i, warmUpActual));
}
//Exec
Long sum = 0l;
Long min = Long.MAX_VALUE;
Long max = Long.MIN_VALUE;
Long actual;
for (int i = 0; i < EXEC_COUNT; i++) {
actual = testedMethod(inputList);
min = Math.min(min, actual);
max = Math.max(max, actual);
sum += actual;
}
Double avg = (double) sum / EXEC_COUNT;
System.out.println(String.format("%s: avg: %f min: %d max: %d", name, avg, min, max));
}
public abstract void _testedMethod(final List<Integer> inputList, List<Integer> outputList);
public Long testedMethod(List<Integer> inputList) {
final List<Integer> outputList = new ArrayList<>(inputList.size());
final Date start = new Date();
_testedMethod(inputList, outputList);
final Date end = new Date();
return end.getTime() - start.getTime();
}
}
Jednotlivé testovací programy se liší pouze v rozdílné implementaci _testedMethod(), pro jednoduchost uvádím jen jeden z nich.
public class RunForeach extends AbstractLoopRunner {
public static void main(String[] args) {
RunForeach runner = new RunForeach("run standard forEach");
runner.run();
}
public RunForeach(String name) {
super(name);
}
@Override
public void _testedMethod(final List<Integer> inputList, List<Integer> outputList) {
for (Integer integer : inputList) {
outputList.add(++integer);
}
}
}
Z hlediska OOP a GOF je to celkem přímočaré a jednoduché řešení, ovšem na to, jak jednoduchý problém řešíme je to docela dost kódu a elegantní kód bych viděl přeci jen trošku jinak. Zřejmě by šel použít i návrhový vzor dekorátor pro dodání funkcionality před a za algoritmus, ale s tím jsem se nikdy prakticky nesetkal a nechce se mi ho studovat. Další variantou je použít návrhový vzor factory, který se se strategy často kombinuje, factory totiž zajistí vrácení správné implementační třídy. Pro implementaci factory nejdříve definuji interface.
public interface ILoopMethod {
void testedMethod(final List<Integer> inputList, List<Integer> outputList);
}
Jedna z variant implementované třídy je:
public class ForEach implements ILoopMethod {
@Override
public void testedMethod(final List<Integer> inputList, List<Integer> outputList) {
for (Integer integer : inputList) {
outputList.add(++integer);
}
}
}
Příslušná zjednodušená factory pak vypadá následovně. V ukázce nejsou implementovány všechny 4 třídy, místo String ve switch bych mohl použít Enum, místo vracení null bych měl vyhodit výjimku a místo new() bych mohl použít dependency injection. Také porušuji princip jednoho výstupního bodu z metody (=jeden return) a chybí mi default, ale na demonstraci principu návrhového vzoru factory strategy to nic nemění.
public class LoopFactory {
public static ILoopMethod get(String name) {
switch (name) {
case "foreach":
return new ForEach();
case "streamForeach":
return new StreamForeach();
}
return null;
}
}
Vlastní implementace a použití se od předchozí varianty příliš neliší. Do konstruktoru předávám instanci implementující ILoopMethod a ta implementuje testovaný algoritmus. Vlastní spuštění výpočtu má na starosti metoda run(), ta se prakticky neliší od předchozí varianty pouze místo _testedMethod() volá metodu deklarovanou interfacem, tedy method.testedMethod(inputList, outputList);
public class LoopRunner {
public LoopRunner(ILoopMethod method) {
this.method = method;
}
public void run() {
...
method.testedMethod(inputList, outputList);
...
}
public static void main(String[] args) {
LoopRunner runner = new LoopRunner(LoopFactory.get("foreach"));
runner.run();
}
}
Výhoda tohoto řešení spočívá mimo jiné i v tom, že pokud si pomůžu již zmíněným DI, např. Springem, tak jsem docela pěkně odstíněn (ode všeho možného) a výsledkem je takové pěkně sofistikované Javovské řešení. Jenže se mi může stát, že na něj někdo (třeba ne-Javista nebo někdo se zbytky selského rozumu) kouká jak tele na nové vrata, protože používám několik návrhových vzorů na úplnou trivialitu. Protože co já vlastně tímto kódem dělám? Když se nad tím trochu zamyslím, tak vlastně předávám metodě funkci jako argument. A na to, jak to lze udělat elegantně, i když ne čistě objektově, se podívám v dalším díle.
- O programování 01 - Úvod
- O programování 02 - Efektivita funkcionálního přístupu v Java 8
- O programování 03 - Přehlednost funkcionálního zápisu v Java 8
- O programování 04 - Java Microbenchmark Harness
- O programování 05 - Další varianty Javovské implementace foreach
- O programování 07 - Funkce jako argument aneb od factory k lambdě v Javě
- O programování 08 - Jak v Javě předat metodu, která bude mít různé parametry
- O programování 09 - Pokročilé využití streamů a funkcionálního přístupu v Java 8
- O programování 10 - Java a inspirace v Ruby
- O programování 11 - Minimalistické programy a Java
- O programování 12 - Paralelní implementace násobení matic v Java - úvod