Interface-y, t.j. rozhrania

<< Abstraktné metódy | Obsah | Balíčky >>

Keď sme rozprávali o zapúzdrení, zdôrazňovali sme, že každé rozumné zadanie sa skladá z dvoch množín požiadaviek.

  • s akými dátami bude náš program pracovať
  • aké služby má poskytovať resp. akú funkcionalitu má mať.

To, ako budeme dáta reprezentovať t.j. v akých štruktúrach, je v požiadavkach zamlčané. Záleží to od konkrétnej voľby programátora. V zadaní je dôležitejšie, že dáta sme schopní nejako reprezentovať. Keďže samotná reprezentácia dát je v privátnych premenných, tak vkladanie a čítanie dát z požiadaviek realizujeme cez metódy (napr. cez settery a gettery). Cez metódy realizujeme aj požadovanú funkcionalitu z druhej množiny požiadaviek.

V čase špecifikovania zadania pre našu triedu, ktorá bude reprezentovať výsledné funkčné riešenie, teda špecifikujeme hlavičky metód a popisujeme čo majú tieto metódy s daným vstupom spraviť, prípadne čo majú vrátiť.

Požiadavky sú kontraktom medzi zadávateľom a riešiteľom, ktorý sa riešiteľ zaväzuje splniť. Zadávateľa nezaujíma ako je to urobené vo vnútri riešenia. Zadávateľa zaujíma iba to, či bol dodržaný kontrakt, teda či metódy robia to, čo sa dohodlo. Riešenie je preňho čiernou skrinkou.

Kontrakt sa v Jave zapisuje vo forme interface-u. Triedy, ktoré spĺňajú požadovanú funkcionalitu interface-u implementujú tento interface.

Napriek tomu však v návrhu existujú triedy, ktoré sú skôr nositeľmi dát a funkcionalita je skôr okrajová, ale i naopak: v návrhu sa objavujú triedy, ktoré majú minimálny počet inštančných premenných, ale sú skôr zamerané na funkcionalitu.

V našom príklade je nositeľom dát Film (a potomkovia), a do druhej skupiny patrí ZoznamFilmov so svojou bohatou funkcionalitou na vyhľadávanie filmov, pridávanie, atď.

Triedy zamerané na funkcionalitu (často nazývané služby) ju často môžu riešiť viacerými spôsobmi. Vezmime si náš ZoznamFilmov: v terajšej podobe reprezentuje filmy, ktoré sú načítavané z textového súboru. Pokojne sa však môže stať, že v neskorších fázach budeme chcieť načítavať súbory z relačnej databázy alebo dokonca z centrálneho súboru umiestneného na internete. Základná množina operácií, ktoré nám zoznam filmov poskytne, je rovnaká bez ohľadu na to, odkiaľ prichádzajú dáta. To opäť súvisí s tým, že pre používateľa sa zoznam filmov bude správať ako čierna skrinka: nebude ho zaujímať ako a odkiaľ bude zoznam získavať dáta, dôležité je, že ich dostane.

A práve túto flexibilitu vieme vyjadriť pomocou interfejsov. Aby sme ich vedeli demonštrovať na príklade filmu, budeme musieť ZoznamFilmov intenzívne prekopať. Zabudnime na starú verziu a navrhnime novú. (To nie je nič výnimočné: vo vývoji softvéru sa často stáva, že kód je už neudržateľný a je nutné ho navrhnúť nanovo a od podlahy, samozrejme s využítím skúseností z predošlých verzií.)

Najprv sa zamyslime nad operáciami, ktoré nám ZoznamFilmov bude ponúkať (prihliadnime pri tom na starú verziu):

  • vypíš všetky filmy
  • vypíš filmy podľa žánru
  • nájdi film podľa názvu
  • vlož film do zoznamu
  • vymaž film podľa názvu

Uvedené operácie vieme priamo zapísať v podobe interfejsu: každej požiadavke zodpovedá jedna metóda:

public interface ZoznamFilmov {
        public void vypisVsetko();

        public void vypisPodlaZanru(String zaner);

        public Film nájdiFilm(String nazov);

        public void vlozFilm(Film film);

        public void vymazFilm(String nazov);

}

Tento interfejs naozaj udáva čo chceme vedieť od zoznamu filmov. Nevraví nič o tom, ako sa správa vo vnútri, kam ukladáme jednotlivé filmy atď. To však nie je jeho úlohou. Používateľa zoznamu filmov vôbec nezaujímajú "črevá", teda vnútorná implementácia. Dôležité je, aby sa naplnil kontrakt a získal požadované informácie.

Pri syntaxi je dôležité neuvádzať žiaden kód do metód. V interfejsi naozaj udávame len hlavičky metód, nepatrí tam žiaden kód!

Zlé príklady sú:

  • void vypisVsetko() {}
  • Film nájdiFilm() { return filmy[0] }

Do interfejsu nemožno uvádzať ani žiadne inštančné premenné! Naozaj, interfejs je len predloha pre funkcionalitu, a inštančná premenná patrí medzi implementačné detaily.

Inak povedané, interfejsy zodpovedajú abstraktným triedam, ktoré

  • nemajú žiadne inštančné premenné
  • všetky ich metódy sú abstraktné.

Implementácie rozhraní

Samozrejme, musí existovať niekto, kto určí, ako sa bude správať trieda, ktorá implementuje požadovanú funkcionalitu. Pri úvahách o spôsobe implementácie sa treba zamýšľať nad viacerými hľadiskami: pamäťovou náročnosťou, časovou zložitosťou, alebo dokonca náročnosťou programovania.

V prípade zoznamu filmov je potrebné zvážiť napr.:

  • odkiaľ budeme načítavať dáta? Zo súboru? Z databázy? Z internetu?
  • v akej dátovej štruktúre ich budeme ukladať? V poli? V zozname?

Úvaha nemusí byť jednoduchá, ale bez ohľadu na zvolený spôsob, je najdôležitejšie dodržaž kontrakt stanovený interfejsom. Používateľa našej triedy vôbec nezaujíma, ktorý spôsob sme si zvolili, ak dostane to, čo chce, bude spokojný. V prípade potreby môžeme dokonca kompletne prekopať kód v "črevách" a používateľ si nič nevšimne. Práve v tom spočíva sila a výhoda interfejsov.

Pre jednoduchosť sa rozhodneme demonštrovať spôsob, kde budeme filmy načítavať zo súboru a ukladať ich do poľa (teda robiť presne to, čo naša pôvodná verzia ZoznamuFilmov:

public class SuborovyZoznamFilmov implements ZoznamFilmov {
        Film[] filmy = new Film[0];

        public void vypisVsetko() {

        }

        public void vypisPodlaZanru(String zaner) {

        }

        public Film nájdiFilm(String nazov) {
                return null;
        }

        public void vlozFilm(Film film) {

        }

        public void vymazFilm(String nazov) {

        }

}

Ak chceme deklarovať, že trieda implementuje interfejs, uvedieme to kľúčovým slovom implements. Je to veľmi podobné ako dedičnosť (ak trieda Lev dedí od triedy Zviera, píšeme class Lev extends Zviera), ale vzhľadom na špecifické vlastnosti interfejsov zvolili dizajnéri svojskú terminológiu a špeciálne kľúčové slovo. Žiadny Java programátor nepovie, že SuborovyZoznamFilmov dedí od interfejsu ZoznamFilmov. Všetci hovoria, že SuborovyZoznamFilmov implementuje interfejs ZoznamFilmov a teda

public class SuborovyZoznamFilmov implements ZoznamFilmov {
...
}

Do triedy sme zároveň uviedli premennú filmy, ktorá bude obsahovať pole filmov.

Ak trieda implementuje interfejs, musí prekryť všetky jeho metódy. Toto je prirodzené: ak raz interfejs reprezentuje kontrakt, podľa ktorého jedna zo strán musí poskytnúť príslušnú funkcionalitu, tak trieda, ktorá sa rozhodla podieľať na tomto kontrakte, je povinná uviesť spôsob, akým ho naplní (tento spôsob je reprezentovaný kódom v metódach). Ak trieda SuborovyZoznamFilmov napĺňa kontrakt zoznamu filmov, nie je mysliteľné, aby sa v nej vynechala implementácia niektorej z metód, pretože to by používateľa tejto triedy zmiatlo a zabránilo realizácii kontraktu.

V predošlom príklade sme teda implementovali všetky metódy, hoci naša implementácia je veľmi biedna a v praxi by táto trieda bola nepoužiteľná. Ale môžeme to skúsiť:

public class SuborovyZoznamFilmovTester {
        public static void main(String[] args) {
                ZoznamFilmov zoznamFilmov = new SuborovyZoznamFilmov();
                Film matrix = zoznamFilmov.nájdiFilm("The Matrix");
                System.out.println(matrix);
                Film casablanca = zoznamFilmov.nájdiFilm("Casablanca");
                System.out.println(matrix);
        }
}

Po spustení tejto triedy získame výpis null a to preto, že metóda nájdiFilm(), tak ako sme ju uviedli vyššie, vždy a stále vracia null.

Zamerajme pozornosť na vytváranie inštancie:

ZoznamFilmov zoznamFilmov = new SuborovyZoznamFilmov();

Na ľavej strane máme premennú typu ZoznamFilmov, čo je interfejs, a na pravej vytvárame konkrétnu inštanciu. Voľne povedané, interfejsu priraďujeme implementáciu. To je úplne korektné, pretože medzi interfejsom ZoznamFilmov a implementáciou SuborovyZoznamFilmov existuje hierarchia dedičnosti a teda všeobecnejšiemu typu (ZoznamFilmov) vieme priradiť konkrétnejší SuborovyZoznamFilmov).

Tento výraz sa dá intepretovať aj inak: vľavo povieme aký kontrakt chceme používať (čo chceme), a vpravo uvedieme triedu, ktorá tento kontrakt realizuje (ako to spravíme).

Pri pohľade na triedu, ktorá implementuje interfejs (realizuje kontrakt) máme jeden vážny problém. Máme len také metódy, ktoré odovzdávajú alebo vypisujú informácie, ale žiadny spôsob, ako naplniť inštanciu zoznamu filmov skutočnými v minulosti vytvorenými dátami. Ale to vyriešime jednoducho tak, že dáta, ktoré sú jedinečné pre daný typ zoznamu filmov pošleme cez konštruktor. Konštruktor totiž nikdy v interfejse neuvádzame, lebo je jedinečný pre kažú triedu, ktorá ho implementuje. Ďalšie spôsoby už vyžadujú hlbšie znalosti javy.

File subor = new File("filmy.txt");
ZoznamFilmov zoznamFilmov = new SuborovyZoznamFilmov(subor);

Takýto konštruktor sme už mali aj minule.

Teraz nám len stačí upraviť metódy v triede SuborovyZoznamFilmov tak, aby naozaj napĺňali kontrakt. Ukážme si to na jednom prípade metódy vlozFilm() a zvyšné metódy ponecháme na pozorného čitateľa.

public class SuborovyZoznamFilmov implements ZoznamFilmov {
...
        public void vlozFilm(Film film) {
                Film[] novePole = new Film[filmy.length + 1];
                for (int i = 0; i < filmy.length; i++) {
                        novePole[i] = filmy[i];
                }
                novePole[filmy.length] = film;
                filmy = novePole;
        }
...
}

Interfejsy ako roly

Ďalším typickým príkladom použitia interfejsov je prípad, keď interface predstavuje "rolu", ktorú môže napĺňať viacero rozličných tried. V Jave je takéto použitie interfejsov bežné:

  • interface Serializable znamená, že trieda, ktorá ho implementuje, môže byť uložená do postupnosti bajtov a uložená napr. do súboru. Serializácia je ako zaváranie ovocia -- vezmeme objekt a uložíme ho na horšie časy, odkiaľ ho v prípade potreby vieme vytiahnuť a použiť. Trieda, ktorá ho implementuje, napĺňa rolu "Zavárateľný" (voľný, ale absolútne nepresný preklad).
  • interface Comparable spomenieme nižšie: predstavuje rolu "porovnateľného" objektu. Inštancia triedy, ktorá ho implementuje, sa dokáže porovnať s inou inštanciou.

Ak by sme definovali interface Nakresliteľný s jedinou metódou nakresliSa(WinPane), akýkoľvek objekt triedy, ktorá tento interfejs implementuje, môže byť nakreslený na WinPane bez ohľadu na to či dedí od Turtle alebo hociakej inej triedy. Takto vieme napr. dosiahnuť triedu EuroMinca, ktorý dedí od Minca a implementuje interfejs Nakresliteľný, aby sa nám nakreslila na plochu.

Alternatívnym príkladom je definovať interface UložiteľnýNaDisk s metódou ulož(File súbor), kde objekt triedy, ktorá tento interfejs implementuje možno uložiť na disk do textového súboru. Ak zmeníme SúborovýZoznamFilmov tak, aby implementoval tento interfejs a dodáme kód, ktorý obsah zoznamu uloží na disk, vieme získať funkcionalitu, ktorou daný súborový zoznam uložíme.

Takéto použitie je veľmi výhodné, pretože sa ním dosahuje viacnásobná dedičnosť, teda trieda môže implementovať viacero interfejsov. Trieda v Jave môže dediť od jedinej triedy (je to zámer autorov), ale môže implementovať viacero interfejsov.

Príkladov na interfejsy ako roly bude ešte viac, dostaneme sa k nim po vysvetlení kolekcií.