Warning
This page is located in archive. Go to the latest version of this course pages. Go the latest version of this page.

4. Cvičení

Info pro ucitele

Toto cvičení je zaměřeno na procvičení práce se spojovými datovými strukturami. V druhé části cvičení se zaměříme na dědičnost a návrhový vzor double dispatch.

Úkoly na cvičení

  1. Pokračujte v práci na nedokončených úlohách v minulém cvičení
  2. Pusťte se do úkolů z části Polymorfismus. Pokuste se zdůvodnit, proč nefunguje single dispatch.

Polymorfizmus

Úkol 1. - Úvod do problematiky

Tento úkol je těžce inspirován tutoriálem od Oracle.

Vytvořte projekt, který bude obsahovat následující třídy:

  • Bicycle - reprezentuje jízdní kolo s následujícími atributy a metodami:
    • protected int cadence;
    • protected int speed;
    • protected int gear;
    • public void printDescription(){}, která vypíše informace o kolu ve tvaru:

Bike is in gear 1 with a cadence of 20 and travelling at a speed of 10. 

  • TestBikes - obsahuje pouze public static void main(), kde budeme naše výtvory zkoušet.

Slovníková definice polymorfismu odkazuje na zásady v biologii, v níž organismus nebo druh může mít mnoho různých forem nebo fáze. Tento princip lze také použít k objektově orientovanému programování a jazyků, tedy i v jazyku Java. Podtřídy třídy mohou definovat své vlastní jedinečné chování a přesto sdílejí některé stejné funkčnosti nadřazené třídy.

Polymorfismus může být demonstrován na třídě Bicycle. Ta obsahuje metodu printDescription(), která vypíše informace o všech datech dané instance.

  • Napište třídy MountainBike a RoadBike, které budou reprezentovat horské a silniční kolo, a které budou dědit od třídy Bicycle.
    • MountainBike bude mít navíc atribut String suspension označující, jakým druhem odpružení kolo disponuje (“Front” pro přední, “Dual” pro přední i zadní).
    • RoadBike bude mít navíc atribut int tireWidth označující šířku pneumatiky v milimetrech (protože silniční kola mívají pneumatiky úzké).

Nyní bychom chtěli, aby printDescription() vypisovalo všechny informace o daném kolu:

Bicycle bike01, bike02, bike03;
 
bike01 = new Bicycle(20, 10, 1);
bike02 = new MountainBike(20, 10, 5, "Dual");
bike03 = new RoadBike(40, 20, 8, 23);
 
bike01.printDescription();
bike02.printDescription();
bike03.printDescription();
Jenže výstup je následující:
Bike is in gear 1 with a cadence of 20 and travelling at a speed of 10. 

Bike is in gear 5 with a cadence of 20 and travelling at a speed of 10. 

Bike is in gear 8 with a cadence of 40 and travelling at a speed of 20.
Informace o odpružení (u druhého kola) a o šířce pneumatik (u třetího kola) chybí. Návrhy jak to vyřešit?

  • Přepište metodu printDescription() u potomků třídy Bicycle tak, aby vždy zahrnovala data specifická pro dané kolo.

Tedy, existují tři třídy: Bicycle, MountainBike a RoadBike. Dvě podtřídy přepisují metodu printDescription() a tisknou jedinečné informace. Jak to, že se volá správná metoda, ačkoliv všechny proměnné jsou typu Bike?

Java Virtual Machine (JVM) volá příslušnou metodu pro objekt, na který se odkazuje každá z proměnných. Není to volání metody určené podle typu proměnné. Toto chování se označuje jako volání virtuální metody a ukazuje určitý aspekt důležitých polymorfních rysů v jazyce Java.

Úkol 2. - Servis kol

Navrhněte servis kol. Servis přijímá různá kola a může mít různé specializace na opravy. Například může být servis, který se specializuje jen na RoadBike ale zvládne i údržbu běžného kola. Zkusme navrhnout následující 3 servisy:

  • BasicService s metodami void accept(Bicycle) - vypíše “fixing Bicycle”, dále void accept(MountainBike) a void accept(RoadBike) - vypíši “can`t fix ” a typ kola.
  • MountainBikeService extends BasicService překyje metodou void accept(MountainBike) - vypíše “fixing MountainBike”
  • RoadBikeService extends BasicService překryje metodu void accept(RoadBike) - vypíše “fixing RoadBike”

Nyní zkusme zakomponovat do metody main tyto servisy a předejme jim kola.

Úkol 3. - Život není snadný, ale nevzdávejme se

Servis nám příjímá kola jako Bicycle. Co s tím? Nápady?

  • Detekce a přetypování
  • Atribut typ pro každou třídu kola a dle něj přetypujeme
  • Nějaký magický trik

Zkusme tedy použít kouzlo, jistě ho oceníte. Nejprve, ale řekněme co je zde za problém. Pokud uděláme toto Bicycle bike = new MountainBike(20, 10, 5, “Dual”); a zavoláme bike.printDescription(); tak se správně zavolá virtuální metoda z MountainBike. Pokud ale zavoláme na mountainBikeService.accept(bike), tak si compiler myslí, že má Bicycle.

Jak na to? Znovu pro pořádek:

  1. zavoláme bike.printDescription(); a správně se zavolá virtuální metoda z MountainBike
  2. zavoláme na mountainBikeService.accept(bike) a compiler si myslí, že má Bicycle.

Co tedy s tím? Spojme to a udělejme double dispatch. Přidejme metodu visit(BasicService bs) do každého typu kola! Každý typ kola pak zavolá v metodě visit bs.accept(this);. Zopakujme si to:

  1. Zavoláme bike.visit(mountainBikeService) - to zavolá virtuální metodu visit na MountainBike - nic nového.
  2. V těle visit se do mountainBikeService předá this, jenže co je nyní this? this je nyní MountainBike a ne Bicycle. Proč? Protože jsme zavolali virtuální metodu nad MountainBike.

Tento trik je opět “best practice” a se jedná o návrhový vzor Visitor.

  1. Je třeba aby každý servis implementoval metody accept pro všechny typy kol.
  2. Každé kolo pak musí mít vlastní metody visit, i když vypadají stejně.

Úkol 4.

Navrhněte třídu držák, ta bude držet instanci daného typu kola (delegace).

  • BicycleHolder
  • MountainBikeHolder extends BicycleHolder
  • RoadBikeHolder extends BicycleHolder

Nápověda

public class MountainBikeHolder extends BicycleHolder {
 
	public MountainBikeHolder(MountainBike bicycle) {
		super(bicycle);
	}
}

Úkol 5.

Navrhněte vozidlo Car, které bude přijímat kola různých typů a dle jejich typu bude vybírat vhodný držák, a to podobně jako jako se kola předávala do servisu (v minulém úkolu), tedy metodami:

  • accept(Bicycle)
  • accept(MountainBike)
  • accept(RoadBike)

Kola se ve vozidle budou ukládat jen s pomocí držáku a to do seznamu/listu:

List<BicycleHolder> carHolders = new ArrayList<BicycleHolder>(4);

Nápověda

Generalizujte auto i opravnu přes rozhraní Visitable, upravte kola ať mohou navštívit toto rozhraní. V našem konkrétním případě je kolo visitor (návštěvník) a servis, potažmo auto je visitable (navštěvovaný).

public interface BicycleVisitable {
	public void accept(Bicycle b);
	public void accept(MountainBike b);
	public void accept(RoadBike b);
} 

public class Bicycle {
..
	public void visit(BicycleVisitable servis) {
		servis.accept(this); // i v dalsich tridach
	}
}

Řešení otestujme:

System.out.println("Single dispatch:");
Car car1 = new Car();
car1.accept(bike01);
car1.accept(bike02);
car1.accept(bike03);
System.out.println(car1);
 
System.out.println("Double dispatch:");
Car car2 = new Car();
bike01.visit(car2);
bike02.visit(car2);
bike03.visit(car2);
System.out.println(car2);

Úkol 6.

Každé kolo bude mít danou barvu, evidujme jen dobře definovaný počet barev výčtovým typem. Výčtovému typu přidejme pro každou barvu též atributy: název barvy a identifikační číslo.

Úkol 7.

Navrhněte generickou třídu AnyHolder<T>, která odstraní hierarchii z úkolu 1. Použijte ji ve druhém autě AnyHolderCar (původní ne-generickou implementaci ponechte, ať máte srovnání před očima).

Bonusy, poznámky, další...

Přetížení vs přepsání (overloading vs overriding)

Zdánlivě podobné, avšak zcela rozdílné koncepty, které si nesmíte plést.

Přetížení

Přetížení (angl. overloading) je případ, kdy jedna třída obsahuje více metod se stejným jménem, ale různou signaturou. Příklad:

class Printer {
    public void print(String string) {
        System.out.println(string);
    }
 
    public void print(int integer) {
        System.out.println(integer);
    }
}

Třída obsahuje dvě metody. Obě se jmenují print, nicméně jedna z nich bere argument typu String a druhá argument typu int. O metodě print řekneme, že je přetížená. Ve cvičení jsme se s přetížením setkali na samém závěru u třídy BasicService, která měla 3 varianty metody accept.

Přepsání

Přepsání (angl. overriding) je případ, kdy třída A má nějakou metodu a třída B, která je potomkem třídy A, definuje vlastní implementaci této metody, neboli ji přepisuje. Při volání takové metody na objektu deklarovaného jako typ A se až v momentě volání zvolí, zda-li se volá metoda třídy A (pokud objekt je skutečně typu A), nebo metoda třídy B (pokud objekt je skutečně typu B). Příklad:

class Printer {
    public void print(String string) {
        System.out.println(string);
    }
}
 
class VerbosePrinter extends Printer {
    @Override
    public void print(String string) {
        System.out.println("I am now printing the following string: \"" + string + "\"");
    }
}

Třídou “A” je zde třída Printer, třídou “B” je zde třída VerbosePrinter, přepisovanou (a jedinou) metodou je zde metoda print.

Abstraktní třídy a metody

V Javě je možné vytvořit takzvanou abstaktní metodu. Jedná se o metodu, u které je definovaná pouze její signatura, ale nikoliv její implementace. Příklad definice abstraktní metody:

public abstract void foo();

Třída, která obsahuje alespoň jednu abstraktní metodu, musí být definována jako abstraktní třída:

public abstract class Abstr {
    public abstract void foo();
}
Obráceně však tato implikace neplatí, tzn. můžeme vytvořit abstraktní třídu (použitím klíčového slova abstract), která však bude mít všechny své metody implementované.

Abstraktní třídy se vyznačují tím, že není možné vytvářet jejich instance. Tzn. volání typu

Abstr x = new Abstr();
nebude možné zkompilovat. Nicméně, levá část (tzn. před rovnítkem) je zcela v pořádku a je možné mít proměnné typované na abstraktní třídu. Budeme-li tedy mít nějakého ne-abstraktního potomka (ne-abstraktní = nemá žádnou abstraktní metodu = všechny abstraktní metody má implementované), můžeme vesele vytvářet instance. Příklad:
public class Concr extends Abstr {
    public void foo() {
        System.out.println("foo");
    }
}
Potom již lze udělat následující volání
Abstr x = new Concr();
x.foo();
a správně se zavolá metoda foo na třídě Concrete (klasické přepsání či overriding metody a virtuální volání).

Rozhraní (interface)

Rozhraní je velice podobné abstraktním třídám. Jedná se o “něco jako třídu”, zkrátka rozhraní, které definuje metody, ale nikoliv jejich implementace. To znamená, že v rozhraní jsou vždy všechny metody abstraktní, a proto se také nemusí psát (a ani nepíše) klíčové slovo abstract. Příklad definice rozhraní:

public interface Iface {
    public void foo();
}
Rozhaní pak jednotlivé třídy implementují:
public class Impl implements Iface {
    public void foo() {
        System.out.println("foo");
    }
}

Třída, která implementuje nějaké rozhraní, musí implementovat všechny metody rozhraní. Nicméně existuje výjimka z tohoto pravidla, a to abstraktní třídy. Pokud je třída abstraktní, tak nemusí implementovat všechny metody (nebo žádnou) ze svých rozhraní. Nebo obráceně - pokud necheme implementovat všechny metody z rozhraní, musíme třídu označit jako abstraktní. Příklad:

public abstract class AbstractImpl implements Iface {
 
}

Rozhraní navíc fungují jako typ. Tzn. je možné (a velmi často vhodné) volání

Iface x = new Impl();

Vám známý List je také rozhraní, nikoliv třída. Jednotlivé implementace (ArrayList, LinkedList) toto rozhraní implementují.

Rozhraní mezi sebou také mohou mít hierarchii, tzn. jedno rozhraní může být potomkem (dědit od) rozhraní jiného. Příkladem budiž opět známá rozhraní List a Collection, kde List je potomkem Collection. Chová se to pak podobně jako u tříd, tzn. potomek má metody jak své tak metody předka, ale jelikož u rozhraní nejsou žádné implementace, tak přepisování (overriding) zde postrádá smysl.

Proč rozhraní

Nabízí se otázka: proč máme v Javě dva podobné mechanismy, a to abstraktní třídy a rozhraní? Důvod je jednoduchý: v jazyce Java může třída dědit pouze od jedné třídy, ale může implementovat libovolný počet rozhraní.

Rozhraní se obecně využívají právě pro definici rozhraní. Tzn. v rozhraní popíši, jak chci, aby s objektem šlo manipulovat (jaké operace na něm budou existovat) a implementaci nechám na konkrétních třídách. Jelikož jsem udělal definici skrze rozhraní, konkrétní třídy stále mohou dědit od jiných tříd.

Krásný příklad je třída Thread a rozhraní Runnable. Jedná se o vlákna, ke kterým se dostaneme až za několik týdnů, ale o samotná vlákna tu nejde. Jde o to, že vlákno je možné vytvořit dvěma způsoby:

  1. Vytvořím potomka třídy Thread.
  2. Vytvořím třídu implementující Runnable a tu pak vložím jako argument do konstruktoru Thread.

První možnost je nejjednodušší, ale už si tím zavřu dveře k dědění od jiné třídy, takže nemůžu použít jinou funkcionalitu. Když ale implementuji rozhraní Runnable, tak jsem pořád schopen vlákno vytvořit, ale stále můžu dědit od jiné třídy.

ENUM

Pro účely karetních her zkusme porovnat reprezentaci barev karet pomocí konstant a pomocí enum. Všechny úkoly zkuste nejprve pomocí konstant:

  1. Vytvořte třídu karta, která bude mít konstruktor obsahující hodnotu a barvu karty,
  2. bude obsahovat metodu toString, která textově vypíše hodnotu a barvu karty.

A nyní pomocí ENUM, pro načtení hodnot můžete použít static import. Napište metody pro výpis a výpis v angličtině (diamonds (♦), spades (♠), hearts (♥) and clubs (♣)). Kde jsou nebezpečí používání konstant?

import java.util.*;
 
public class Card {
    public enum Rank { DEUCE, THREE, FOUR, FIVE, SIX,
        SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING, ACE }
 
    public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }
 
    private final Rank rank;
    private final Suit suit;
    private Card(Rank rank, Suit suit) {
        this.rank = rank;
        this.suit = suit;
    }
 
    public Rank rank() { return rank; }
    public Suit suit() { return suit; }
    public String toString() { return rank + " of " + suit; }
 
    private static final List<Card> protoDeck = new ArrayList<Card>();
 
    // Initialize prototype deck
    static {
        for (Suit suit : Suit.values())
            for (Rank rank : Rank.values())
                protoDeck.add(new Card(rank, suit));
    }
 
    public static ArrayList<Card> newDeck() {
        return new ArrayList<Card>(protoDeck); // Return copy of prototype deck
    }
}

Enum jak ho neznáme

Řekněme, že chceme pracovat s elementy, které je možné řadit vzestupně nebo sestupně. Mějme následující výchozí Enum:

public enum Ordering {
    ASCENDING,
    DESCENDING;
}

Atributy

Enum může mít atributy, stejně jako jakákoliv jiná třída. Tyto atributy lze nastavit v konstruktoru, který lze definovat. Deklarace jednotlivých prvků jsou vlastně volání konstruktoru (akorát když žádný není, vynecháme závorky) a lze tak předat atributům hodnoty. Uložme si pro každé seřazení jeho zkratku:

public enum Ordering {
    ASCENDING("ASC"),
    DESCENDING("DESC");
 
    public final String acronym;
 
    private Ordering(String acronym) {
        this.acronym = acronym;
    }
}
Atribut můžete použít stejně jako u jakékoli jiné třídy, např.:
Ordering[] ords = Ordering.values();
for (Ordering o : ords) {
    System.out.println(o.acronym + " - " + o);
}
Produkuje:
ASC - ASCENDING
DESC - DESCENDING

Metody

Enum může mít metody, stejně jako jakákoliv jiná třída. A jelikož i Enum je potomkem třídy Object (stejně jako všechny třídy v javě), ukažme to na metodě toString():

public enum Ordering {
    ASCENDING("ASC"),
    DESCENDING("DESC");
 
    public final String acronym;
 
    private Ordering(String acronym) {
        this.acronym = acronym;
    }
 
    @Override
    public String toString() {
        return this.name() + "(" + acronym + ")";
    }
}
Následující kód
for (Ordering o : Ordering.values()) {
    System.out.println(o);
}
pak produkuje
ASCENDING(ASC)
DESCENDING(DESC)

Rozhraní

Stejně jako jakákoliv jiná třída, i Enum může implementovat rozhraní (interface). Implementujme na našem Ordering rozhraní java.util.Comparator pro Integer. Máme dvě možnosti.

1. Speciální implementace pro každý prvek

Každý prvek si implementuje vlastní verze metod rozhraní:

public enum Ordering implements Comparator<Integer> {
    ASCENDING {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1.compareTo(o2);
        }
    },
    DESCENDING {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o2.compareTo(o1);
        }
    };
}
Ukázkový kód:
List<Integer> elementsBase = Arrays.asList(3, 1, 40, 42, 42, 51, -134, 0, 0);
List<Integer> sortedAsc = new ArrayList<Integer>(elementsBase);
Collections.sort(sortedAsc, Ordering.ASCENDING);
List<Integer> sortedDesc = new ArrayList<Integer>(elementsBase);
Collections.sort(sortedDesc, Ordering.DESCENDING);
System.out.println(sortedAsc);
System.out.println(sortedDesc);
Produkuje následující výstup:
ascending: [-134, 0, 0, 1, 3, 40, 42, 42, 51]
descending: [51, 42, 42, 40, 3, 1, 0, 0, -134]

2. Jediná společná implementace s vnitřním rozhodováním na základě ''this''

Metody rozhraní jsou implementovány pouze jednou, na stejné úrovni jako např. konstruktor a až uvnitř nich se rozhodneme, co budeme dělat na základě toho, co je vlastně this:

public enum Ordering implements Comparator<Integer> {
    ASCENDING,
    DESCENDING;
 
    @Override
    public int compare(Integer o1, Integer o2) {
        switch (this) {
            case ASCENDING:
                return o1.compareTo(o2);
            case DESCENDING:
                return o2.compareTo(o1);
        }
        throw new IllegalStateException(); // we can never reach this code but without it
                                           // compiler complains that the method might not
                                           // return anything
    }
}
Chová se totožně jako předchozí varianta. Nicméně, tento postup se nedoporučuje. Navíc ani není moc hezký (nedosažitelná výjimka na konci) a není to správně objektové.

Ještě jedna poznámka k metodám

Metody samotné lze také implementovat pro každý prvek zvlášť, jako jsme viděli u rozhraní. Náš toString() by tedy mohl vypadat i takto:

public enum Ordering {
    ASCENDING {
        @Override
        public String toString() {
            return this.name() + "(ASC)";
        }
    },
    DESCENDING {
        @Override
        public String toString() {
            return this.name() + "(DESC)";
        }
    };
}
Kterou ze dvou variant použít, je otázka. Obecné pravidlo by se dalo vyložit asi takto: pokud je chování metody specifické pro každý prvek (dá se zhruba říct i jako “bylo by třeba dělat switch(this)”), je vhodné ji implementovat pro každý prvek zvlášť. Pokud je chování pro všechny prvky víceméně shodné (či “není třeba dělat switch(this)”), je vhodné ji implementovat na úrovni Enumu.

Další studium

Pro maximální porozumění (nejen) Enumům doporučuji Chapter 6, Items 30-34 z knihy Effective Java, 2nd Edition.

Generika

Generika umožňují vytvářet třídy a metody “on demand” parametrizované nějakou jinou třídou (ale nikoliv např. číslem, což je možné např. v C++, kde se to nazývá “templates” neboli šablony).

Generická třída a rozhraní

Jednoduchý příklad:

/**
 * Generic version of the Box class.
 * @param <T> the type of the value being boxed
 */
public class Box<T> {
    // T stands for "Type"
    private T t;
 
    public void set(T t) { this.t = t; }
    public T get() { return t; }
}
A zde volání
Box<Integer> integerBox = new Box<Integer>();
Jiný a lepší příklad
public interface Pair<K, V> {
    public K getKey();
    public V getValue();
}
 
public class OrderedPair<K, V> implements Pair<K, V> {
 
    private K key;
    private V value;
 
    public OrderedPair(K key, V value) {
	this.key = key;
	this.value = value;
    }
 
    public K getKey()	{ return key; }
    public V getValue() { return value; }
}
inicializace OrderedPair class:
Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
Pair<String, String>  p2 = new OrderedPair<String, String>("hello", "world");
Java 7 zkrátí na
OrderedPair<String, Integer> p1 = new OrderedPair<>("Even", 8);
OrderedPair<String, String>  p2 = new OrderedPair<>("hello", "world");
Pro ty co se neztratili
OrderedPair<String, Box<Integer>> p = new OrderedPair<>("primes", new Box<Integer>(...));

Generická metoda

Nemusí být generická celá třída, generickou lze udělat i samotnou metodu:

public class ListCreator {
    public static <T> List<T> createList(T ... elements) {
        List<T> list = new ArrayList<T>(elements.length);
        for (T element : elements) {
            list.add(element);
        }
        return list;
    }
}
Ono <T> před signaturou metody je ekvivalentní stejnému <T> za názvem třídy, akorát negeneralizujeme celou třídu, ale samotnou metodu.

Když generické metody voláme, můžeme buď nechat typ odvodit:

List<Integer> ints = ListCreator.createList(1, 3);
System.out.println(ints);
System.out.println("Types: " + ints.get(0).getClass().getSimpleName() +
        ", " +ints.get(1).getClass().getSimpleName());
List<String> strings = ListCreator.createList("ab", "cd");
System.out.println(strings);
System.out.println("Types: " + strings.get(0).getClass().getSimpleName() +
        ", " +strings.get(1).getClass().getSimpleName());
Produkuje
[1, 3]
Types: Integer, Integer
[ab, cd]
Types: String, String
Nebo jej ručně specifikovat voláním ve tvaru:
List<Integer> ints = ListCreator.<Integer>createList(1, 3);
Zde ale postrádá smysl, protože není jiná možnost než tato.

courses/b0b36pjv/tutorials/04/start.txt · Last modified: 2019/03/18 14:21 by mudromar