# Observer

V predchádzajúcich dvoch kapitolách sme si ukázali spôsob, ako dokážeme abstrahovať jadro algoritmu a umožniť špecifikovať konkrétne správanie cez template metódu, resp. stratégiu. Dosiahli sme, že náš kód sa stal modulárnym.

Modulárnosť je dôležitá vlastnosť, nielen kvôli flexibilite programu, ale aj kvôli zlepšeniu testovateľnosti jednotlivých častí. Preto je vhodné ju aplikovať aj vtedy, keď samotný algoritmus procesu korigovať nepotrebujeme, ale stačí nám len vykonať určité akcie pri nastaní daných udalostí. Jedným z patternov, ktorý v tomto smere využívame, je pozorovateľ - anglicky observer.

# Modelová situácia

Návrhový vzor observer si ukážeme na príklade z používateľských prostredí - budeme zachytávať udalosť kliknutia na UI prvok. V tomto prípade je zaužívaný názor pre observer poslúchač - anglicky listener. Napriek inému menu však stále ide o ten istý vzor.

# Jednoakciový observer

Aby sme mohli zachytávať udalosti používateľského prostredia, potrebujeme najprv mať definované nejaké prvky. Začnime veľmi jednoducho - s tlačidlom. To bude pre naše účely obsahovať len meno, na základe ktorého ho vieme identifikovať, a verejnú metódu performClick, ktorá bude simulovať stlačenie tlačidla používateľom:









 
 
 






public class Button {

    private final String name;

    public Button(String name) {
        this.name = name;
    }

    public void performClick() {
        // simulate button click
    }

    public String getName() {
        return name;
    }
}

Samozrejme, tlačidlo nám bude úplne zbytočné, ak pri jeho stlačení nebudeme vedieť nič vykonať. Preto potrebujeme triede predať referenciu na akciu, ktorú zavolá pri každom stlačení. Na tento účel môžeme využiť funkcionálne rozhranie Runnable zo štandardnej Java knižnice, ktoré je zložené z metódy run, ktorá neprijíma žiaden argument a nevracia žiadnu hodnotu. Takýto observer nazveme onClickListener a vygenerujeme pre neho setter:

public class Button {

    private Runnable onClickListener = null;

    public void setOnClickListener(Runnable onClickListener) {
        this.onClickListener = onClickListener;
    }

    // Rest of the class omitted for brevity
}

V metóde performClick pri simulovanom kliknutí ho už len musíme zavolať. Nakoľko však observer nemusí byť vždy nastavený (je to validný stav), musíme si odkontrolovať, že nie je null:

public void performClick() {
    if (onClickListener != null) {
        onClickListener.run();
    }
}

V JShell si môžeme kód otestovať a presvedčiť sa, že všetko funguje, ako má:






 

 
 

> Button button = new Button("button_1");
Defined field Button button = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@329c3aeb

> button.performClick();

> button.setOnClickListener(() -> System.out.println("Clicked"));

> button.performClick();
Clicked

Naše používateľské rozhranie však v praxi bude pozostávať z podstatne viac ako jedného tlačidla, pričom definované akcie môžu byť podobné až na malé výnimky podľa konkrétneho tlačidla. Pozrite sa na príklad, kde po stlačení oboch tlačidiel vypíšeme do konzoly, že bolo stlačené, no zmeníme len informáciu, že ktoré konkrétne:




 




 

> Button button1 = new Button("button_1");
Defined field Button button1 = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@329c3aeb

> button1.setOnClickListener(() -> System.out.println("First button clicked!"));

> Button button2 = new Button("button_2");
Defined field Button button2 = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@23fda423

> button2.setOnClickListener(() -> System.out.println("Second button clicked!"));

V tomto prípade by bolo úplne prirodzené, ak by sa o túto funkcionalitu staral len jeden observer, no problém je, že nemá informáciu o tom, aké tlačidlo bolo stlačené. Tento problém sa v praxi rieši veľmi jednoducho - volajúca trieda predá svoju inštanciu observeru, čím mu umožní registrovať sa do viacerých tlačidiel a pri zavolaní si skontrolovať, akú akciu má vykonať. Implementovanie tejto zmeny je triviálne - namiesto Runnable použijeme funcionálne rozhranie Consumer<T>, ktoré obsahuje metódu accept, ktorá akceptuje argument typu T a nevracia žiadnu hodnotu. Upravená trieda Button vyzerá nasledovne:



 



 



 






public class Button {

    private Consumer<Button> onClickListener = null;

    public void performClick() {
        if (onClickListener != null) {
            onClickListener.accept(this);
        }
    }
    
    public void setOnClickListener(Consumer<Button> onClickListener) {
        this.onClickListener = onClickListener;
    }

    // Rest of the class omitted for brevity
}

Príklad hore tak teraz vieme zovšeobecniť nasledovne:







 


















 
 

 
 

> Button button1 = new Button("button_1");
Defined field Button button1 = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@329c3aeb

> Button button2 = new Button("button_2");
Defined field Button button2 = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@23fda423

> Consumer<Button> onClickListener = button -> {
    String buttonName = "Unknown";
    switch (button.getName()) {
        case "button_1":
            buttonName = "First";
            break;

        case "button_2":
            buttonName = "Second";
            break;
    }
    System.out.println(buttonName + " button clicked!");
};
Defined field Consumer<Button> onClickListener = java.util.function.Consumer@bf39ad98

> button1.setOnClickListener(onClickListener);

> button2.setOnClickListener(onClickListener);

> button2.performClick();
Second button clicked!

> button1.performClick();
First button clicked!

Náš jednoduchý observer je teda nateraz hotový, no stále máme ešte čo vylepšovať. V úvode sme spomínali, že našim cieľom je písať modulárny, na sebe nezávislý kód. Teraz to však nie je úplne možné - ak by sme chceli na udalosť kliknutia tlačidla nasadiť 2 rozdielne funkcionality, museli by sme použiť jeden observer, ktorý by ich obe vykonal. Tomu by sme sa však radi vyhli, nakoľko by to znamelo zbytočne spájať nesúvisiaci kód dokopy. Riešením je umožniť na tlačidlo nastaviť nie len jeden, ale (teoreticky) neobmedzene veľa observerov. Na to musíme zrefaktorovať triedu Button nasledovne:



 


 


 
 





public class Button {

    private Set<Consumer<Button>> onClickListeners = new HashSet<>();

    public void performClick() {
        onClickListeners.forEach(listener -> listener.accept(this));
    }

    public void registerOnClickListener(Consumer<Button> onClickListener) {
        onClickListeners.add(onClickListener);
    }

    // Rest of the class omitted for brevity
}

Do pozornosti dávame nasledovné zmeny:

  • na ukladanie observerov sme použili množinu (Set) a nie zoznam (List), čo nám zaručuje, že ten istý observer (ten istý objekt) bude možné zaregistrovať len raz
  • setter observera sme premenovali na register..., aby bolo jasné, že observer nemeníme, ale pridávame. Alternatívne sme metódu mohli nazvať aj add...

S týmito úpravami bude riešenie vyššie spomenutej situácie triviálne:

> Button button = new Button("button_1");
Defined field Button button = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@329c3aeb

> button.registerOnClickListener(() -> System.out.println("Clicked"));

> button.registerOnClickListener(()> System.out.println("Other action"));

> button.performClick();
Clicked
Other action

Na záver si ešte pridáme "čerešničku na torte" - môže nastať situácia, kedy už nebudeme musieť niektoré akcie pri stlačení tlačidla vykonať. Preto sa nám môže hodiť metóda unregisterOnClickListener, ktorá odstráni observer z vnútorného zoznamu:

public void unregisterOnClickListener(Consumer<Button> onClickListener) {
    onClickListeners.remove(onClickListener);
}

Poznámka

HashSet negarantuje poradie, v akom sa observery budú volať. Ak potrebujete, aby sa akcie volali v tom poradí, v akom boli registrované, použite niektoré z dostupných alternatív, napr. LinkedHashSet.

# Komplexnejší observer

Klienti boli s našim tlačidlom nadmieru spokojní a požiadali nás, aby sme rozšírili ich funkcionalitu aj o dlhé a dvojité stlačenie. Ako by sme niečo takéto riešili?

Úplne prvá vec, ktorá nás môže napadnúť, je pridanie ďalších observerov pre nové udalosti za použitia funkcionálneho rozhrania Consumer. Je nám však jasné, že ak by sme neskôr museli pridať akcie pre ďalšie udalosti, sprievodný kód by začal neúmerne narastať.

Rozhodneme sa teda zostať pri jednom observery, ktorý použijeme pre všetky typy udalostí. Teraz sa môžeme pozrieť do Java knižnice - môžeme využiť ďalšie funkcionálne rozhranie BiConsumer, ktoré akceptuje 2 argumenty a nevracia žiadnu hodnotu, pričom by sme mu predali ako prvý argument identifikátor akcie. Ten by mohol byť typu String, resp. najlepšie nejaká vlastná enumerácia. Opäť, nie je to fundamentálne zlý nápad, no nie je škálovateľný - ak by sme chceli pridať ďalší argument observera, zistili by sme, že Java žiadny TriConsumer nemá.

Preto je pre nás najlepšie riešenie odpútať sa od štandardnej Java knižnice a vytvoriť si vlastné rozhranie definujúce náš observer. To bude s podporou všetkých troch udalostí vyzerať nasledovne:

public interface OnClickListener {

    void onClick(Button button);

    void onDoubleClick(Button button);

    void onLongClick(Button button);
}

Všetky metódy akceptujú tlačidlo ako svoj argument, aby sme zachovali predchádzajúcu funkcionalitu. Ich mená sú v tvare on[ACTION], kde ACTION je buď kliknutie, dvojité kliknutie alebo dlhé kliknutie. Takto je hneď jasné, na čo každá metóda slúžy a kedy bude zavolaná.

Poďme teraz nahradiť Consumer v triede Button týmto novým rozhraním:



 


 


 



 






public class Button {

    private Set<OnClickListener> onClickListeners = new HashSet<>();

    public void performClick() {
        onClickListeners.forEach(listener -> listener.onClick(this));
    }

    public void registerOnClickListener(OnClickListener onClickListener) {
        onClickListeners.add(onClickListener);
    }

    public void unregisterOnClickListener(OnClickListener onClickListener) {
        onClickListeners.remove(onClickListener);
    }

    // Rest of the class omitted for brevity
}

Zároveň pridáme metódy pre simulovanie dlhého a dvojitého kliknutia, ktoré budú ekvivalentné s existujúcou pre jednoduché kliknutie:

public class Button {

    public void performDoubleClick() {
        onClickListeners.forEach(listener -> listener.onDoubleClick(this));
    }

    public void performLongClick() {
        onClickListeners.forEach(listener -> listener.onLongClick(this));
    }

    // Rest of the class omitted for brevity
}

Všetko je hotové - poďme si zmeny vyskúšať v JShell:

> Button button = new Button("button_1");
Defined field Button button = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.Button@329c3aeb

> OnClickListener listener = new OnClickListener {

    public void onClick(Button button) {
        System.out.println("Button clicked!");
    }

    public void onDoubleClick(Button button) {
        System.out.println("Button double clicked!");
    }

    public void onLongClick(Button button) {
        System.out.println("Button long clicked!");
    }
};
Defined field OnClickListener listener = sk.tuke.fei.kpi.oop.chapters.behavioral.observer.OnClickListener@4fda873c

> button.registerOnClickListener(listener);

> button.performClick();
Button clicked!

> button.performDoubleClick();
Button double clicked!

> button.performLongClick();
Button long clicked!

Pri používaní tohto observeru by sme si ale veľmi rýchlo všimli, že častokrát by sme využívali len jedno metódu a zvyšné nechali prázdne. Niekoľko riadkov by teda bolo úplne zbytočných a len by zahlcovalo kód:







 

 


OnClickListener listener = new OnClickListener {

    public void onClick(Button button) {
        System.out.println("Button clicked!");
    }

    public void onDoubleClick(Button button) { }

    public void onLongClick(Button button) { }
};

Najlepšie by pre nás bolo, ak by sme onDoubleClick a onLongClick vedeli použiť len vtedy, keď nám ich treba. Aby sme mohli niektorú z nich vynechať, resp. použiť observer ako funcionálne rozhranie:

OnClickListener listener = new OnClickListener {

    public void onClick(Button button) {
        System.out.println("Button clicked!");
    }

    public void onLongClick(Button button) {
        System.out.println("Button long clicked");
    }

    // notice that onDoubleClick is missing, default
    // empty implementation is used instead
};

// both onLongClick and onDoubleClick will be replaced
// by default empty implementation
OnClickListener lambdaListener = button -> System.out.println("Button clicked");

Našťastie, vieme to od Javy 8 dosiahnuť pomocou kľúcového slova default. Tým označíme metódy onDoubleClick a onLongClick, čo nám umožní špecifikovať predvolenú implementáciu. Tá je, v našom prípade, prázdna. A keďže nám ostala 1 abstraktná metóda, ak potrebujeme špecifikovať len ju, vieme to urobiť (aj) pomocou funcionálneho zápisu, aký sme si ukázali hore. Celkové úpravy rozhrania teda vyzerajú nasledovne:

 




 

 


@FunctionalInterface
public interface OnClickListener {

    void onClick(V view);

    default void onDoubleClick(V view) { }

    default void onLongClick(V view) { }
}

Poznámka

Anotácia FunctionalInterface je voliteľná, pokiaľ ju však pridáme, kompilátor vyhodí chybu a tým nás upozorní, ak by OnClickListener už nespĺňal podmienky funkcionálneho rozhrania.

# Best practices

  • Observery označujte suffixom Observer alebo Listener
  • Pokiaľ je observer vnorená trieda, môže sa volať len Observer alebo Listener
  • Je vhodné, ak volajúca trieda observera predá svoju inštanciu observeru
  • Ak je to možné a vhodné, snažte sa udržať observer ako funcionálne rozhranie. Pomôžte si anotáciou FunctionalInterface.
  • Využívajte možnosť definovať prázdne predvolené implementácie pre metódy observera, aby bol klientsky kód prehľadnejší
  • Dbajte na poradie volaní. Je rozdiel, či observer zavoláte pred, resp. po tom, ako sa daná udalosť uskutoční. Z názvu metódy observera by malo byť jasné, v ktorom momente je akcia volaná - ak nie, vysvetlite chovanie cez Javadoc.

# Úlohy

# 1. Zovšeobecnite OnClickListener pre ľubovoľný grafický element

Náš observer má už všetko, čo treba, no nie je univerzálny - dá sa použiť iba pre tlačidlá. Bolo by teda viac než vhodné urobiť implementáciu, ktorá by pokrývala hocijaký grafický element.

Zovšeobecnite OnClickListener pomocou generického programovania, aby sa dal použiť s hocijakým grafickým elementom. Aby ste to mohli otestovať, vytvorte triedu pre všeobecný grafický prvok View a nechajte Button, aby od nej dedil, a zároveň vytvorte aj triedu Label, ktorý bude taktiež dediť od View.

Nezabudnite dodržať DRY princíp - funkcionalitu s registráciou observeru preneste do triedy View. Opäť si pomôžte generickým programovaním.

# 2. Observable List

V rámci tejto úlohy si vyskúšate, ako sa vytvára observer pre už existujúce triedy. Cieľom je vytvoriť triedu ObservableList, ktorá umožní sledovanie udalostí ako napr. pridanie a odobranie prvku z daného zoznamu.

V prvom kroku vytvorte dekorátor rozhrania List, kde implementujete všetky metódy a sprostredkujete ich volania obalenému zoznamu. Ubezpečte sa, aby sa volania metód ObservableList správali rovnako, ako volania metód obaleného zoznamu. Následne vytvorte observer, ktorý umožní zachytávať nasledovné udalosti:

  • Pridanie prvku
  • Odstránenie prvku
  • Pridanie viacerých prvkov naraz
  • Odstránenie viacerých prvkov naraz
  • Zachovanie viacerých prvkov naraz
  • Vyprázdnenie listu
  • Nastavenie (výmena) prvku na konkrétnej pozícii

Nakoľko žiadna z akcií nie je dominantná, observer nemusí byť funkcionálne rozhranie a všetky metódy môžu byť default s prázdnou implementáciou, aby sa znížil počet nepotrebných riadkov v klientskom kóde. Taktiež môžete observer umiestniť ako vnorené rozhranie triedy ObservableList.

Snažte sa pri tejto úlohe dodržať zásady best practices.

# Riešenia úloh

Nasledujúce kódy obsahujú riešenia zo všetkých úloh.

# Pozri tiež

# Diskusia