Java SE 8 Documentation
Feladatok

Névtelen (belső) osztályok

A névtelen osztályok (anonymous class) olyan belsõ osztályok (inner class), amelyek mert rendelkeznek névvel, mert a definíciójukat közvetlenül a felhasználásuk helyén fejtjük ki. Ezzel nem egyszerűen csak "ad hoc" módon tudunk osztályokat készíteni, hanem a környezetével, a külső osztállyal (outer class) is tud kommunikálni, hozzáfér az összes eleméhez.

Például:

public class HelloWorldAnonymousClasses {

    interface HelloWorld {
        public void greet();
        public void greetSomeone(String someone);
    }

    public void sayHello() {

        class EnglishGreeting implements HelloWorld {
            String name = "world";

            public void greet() {
                greetSomeone("world");
            }

            public void greetSomeone(String someone) {
                name = someone;
                System.out.println("Hello " + name + "!");
            }
        }

        HelloWorld englishGreeting = new EnglishGreeting();

        HelloWorld frenchGreeting = new HelloWorld() {
            String name = "tout le monde";

            public void greet() {
                greetSomeone("tout le monde");
            }

            public void greetSomeone(String someone) {
                name = someone;
                System.out.println("Salut " + name + "!");
            }
        };

        HelloWorld spanishGreeting = new HelloWorld() {
            String name = "mundo";

            public void greet() {
                greetSomeone("mundo");
            }

            public void greetSomeone(String someone) {
                name = someone;
                System.out.println("Hola, " + name + "!");
            }
        };

        englishGreeting.greet();
        frenchGreeting.greetSomeone("Fred");
        spanishGreeting.greet();
    }

    public static void main(String... args) {
        HelloWorldAnonymousClasses myApp =
            new HelloWorldAnonymousClasses();
        myApp.sayHello();
    }
}

A névtelen osztályok segítségével egyszerűsíteni tudjuk a programjainkat, mivel egyszerre tudjuk deklarálni és példányosítani az osztályt. Igazából olyanok, mint a belső osztályok, csak nem rendelkeznek névvel. Akkor érdemes használni ezeket, ha egy belső osztályt csak egyszer akarunk használni.

Fontos azonban megjegyezni, hogy a névtelen osztályok kifejezések, tehát az ilyen osztályok bárhol alkalmazhatóak, ahova kifejezéseket tudunk írni.

Például:

HelloWorld frenchGreeting = new HelloWorld() {
    String name = "tout le monde";

    public void greet() {
        greetSomeone("tout le monde");
    }

    public void greetSomeone(String someone) {
        name = someone;
        System.out.println("Salut " + name + "!");
    }
};

A névtelen osztályokat egy konstruktorhíváshoz hasonlóan adjuk meg, annyi különbséggel, hogy mellette megadjuk magát az osztályt is. Tehát egy névtelen osztályt tartalmazó kifejezés a következő részekből áll:

  • a new operátor,
  • a megvalósítandó interfész vagy a kiterjesztendő osztály neve,
  • zárójelek között a konstruktor paraméterei (amikor a konstruktornak nincsenek paraméterei, akkor üres zárójelpár),
  • az osztály definíciója (metódusokkal).

A névtelen osztályok a következő módon tudják elérni a befoglaló osztály elemeit:

  • az adattagok elérhetőek, láthatósági megszorítástól függetlenül,
  • a definícióját tartalmazó blokkban található lokális változók közül csak azokat, amelyek final értékűek,
  • a befoglaló osztályban és blokkban található nevekkel azonos névvel rendelkező deklarációk elfedik a külső elemeket.

A névtelen osztályok adattagjaira a következő megszorítások vonatkoznak:

  • statikus inicializálók vagy taginterfészek nem deklarálhatóak,
  • a statikus adattagok csak konstansok lehetnek,
  • nem adhatóak meg láthatósági módosítók.

Névtelen osztályokban a következő elemek szerepelhetnek:

  • adattagok,
  • metódusok,
  • példányszintű inicializáló-kifejezések, és
  • belső osztályok.

A névtelen osztályokban konstruktor nem adható meg!

λ-kifejezések

A névtelen osztályok használata olyan esetekben viszont körülményes, amikor a bennük szereplő programok rövidek. Például, amikor a megvalósított interfész csak egyetlen metódust tartalmaz. Ezeket neveztük korábban stratégiáknak. A Java 8-as verziójától kezdődően azonban lehetőségünk van ún. λ-kifejezések használatára is, amikor függvényeket tudunk átadni metódusok paramétereként, vagy hogy programokat adatként tudjunk kezelni.

Ennek tárgyalásához most tekintsük a következő programot példaként. Ebben egy közösségi hálót akarunk megvalósítani, ahol annak tagjait a Person osztállyal írjuk le a következőképpen (vázlatosan):

public class Person {

    public enum Sex { MALE, FEMALE }

    String name;
    LocalDate birthday;
    Sex gender;
    String emailAddress;

    public int getAge() {
        // ...
    }

    public void printPerson() {
        // ...
    }
}

Képzeljük el, hogy az alkalmazásban a Person típusú objektumokból egy listát tartunk fenn, vagyis egy List<Person> objektumban tároljuk ezeket. Ebben a listában akarunk keresni elemeket, amelyek megfelelnek valamilyen szűrési feltételnek. Tekintsük át, milyen módon tudnánk ezt megvalósítani!

A legegyszerűbb megoldás talán az lehetne, hogy létrehozunk külön metódusokat az egyes keresésekhez. Például az alábbi metódussal azokat a szociális háló azon tagjait írjuk ki, amelyek egy adott kornál idősebbek:

public static void printPersonsOlderThan(List<Person> roster, int age) {
    for (Person p : roster) {
        if (p.getAge() >= age) {
            p.printPerson();
        }
    }
}

Ez a megoldás viszont nem elég rugalmas, mivel a keresési lehetőségeket ezzel rögzítetten beépítjük a programba. Helyette sokkal jobb lenne, ha a hívó akár a hívás helyén tudna definiálni kereséseket. Ezt például a kód alábbi szervezésével tudjuk jobban elősegíteni:

public static void printPersons(List<Person> roster, CheckPerson tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

interface CheckPerson {
    boolean test(Person p);
}

class CheckPersonEligibleForSelectiveService implements CheckPerson {
    public boolean test(Person p) {
        return p.gender == Person.Sex.MALE
            && p.getAge() >= 18
            && p.getAge() <= 25;
    }
}

Itt a adott egy külön CheckPerson osztály és annak a test() metódusa, amellyel a metóduson kívülről meg tudjuk mondani, pontosan milyen objektumokat engedünk kiíratni. Ez lényegében így egy stratégia alkalmazása:

printPersons(roster, new CheckPersonEligibleForSelectiveService());

Ez így még mindig nem a lehető legjobb változat, mivel minden egyes kereséshez kell definiálnunk egy külön osztályt (amely viszont lehet belső vagy a csomagon belül rejtett). Ettől egy fokkal léphetünk előre úgy, ha az osztályt közvetlenül a hívás helyén fejtjük ki, tehát egy névtelen osztály alkalmazásával:

printPersons(
    roster,
    new CheckPerson() {
        public boolean test(Person p) {
            return p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25;
        }
    }
);

A CheckPerson kapcsán azonban megjegyezzük, hogy ez egy ún. funkcionális interfész. Funkcionálisnak nevezünk egy interfészt, ha csak és kizárólag egyetlen absztrakt (implementálandó) metódust tartalmaz. Ez azért fontos, mert a megvalósítandó metódus neve és leírása következmények nélkül elhagyhatóvá válik és ebből kapunk egy ún. λ-kifejezést.

printPersons(
    roster,
    (Person p) -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25
);

A λ-kifejezések megadásának szabályai:

  • Itt lényegében egy névtelen metódusról van szó, amelynek a nevét nem, csupán a formális paramétereit kell névvel és típussal felsorolni, vesszőkkel elválasztottan és zárójelek között. A típus viszont a gyakorlatban elhagyható, mivel egyébként is tudni fogja a fordító. Illetve a zárójelek is, amennyiben csak egyetlen paraméterrel van szó. Tehát a következő λ-kifejezés is teljesen szabályosnak tekinthető:

     p -> p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25
  • A formális paraméterlistát egy -> (nyíl) követi.

  • A nyíl után következik a névtelen metódus törzse. Ez lehet egyetlen kifejezés vagy utasítások sorozata, egy blokk. Például a

     p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25

    kifejezés felírható eképpen is:

     p -> {
       return p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25;
     }

    Mivel azonban itt a return utasítás, ezért blokkba kell tenni a λ-kifejezés törzsében. Amikor viszont visszatérési érték nélkül (void) metódusokkal dolgozunk, a blokk elhagyható:

     p -> p.printPerson()

    Valamint, amikor a törzs csak egyetlen metódusra történő hivatkozást tartalmaz, mint például ebben az esetben:

     Arrays.sort(rosterAsArray, (a, b) -> Person.compareByAge(a, b));

    akkor az rövidíthető a következő módon:

     Arrays.sort(rosterAsArray, Person::compareByAge);

    Ez a szintaktikai egyszerűsítés konstruktoroknál is bevethető. Például ahelyett, hogy azt írnánk:

     Set<Person> rosterSetLambda = transferElements(roster, () -> { return new HashSet<>(); });

    tulajdonképpen elegendő csak ennyi:

     Set<Person> rosterSet = transferElements(roster, HashSet::new);

A λ-kifejezések a belső és névtelen osztályokhoz hasonlóan hozzáférnek a befoglaló környezet lokális változóihoz. Azonban nem örökölnek semmilyen nevet az őstípusból és nem vezethetnek be új változókat, így nem is tudnak elfedni másokat. A belső és névtelen osztályokhoz hasonlóan a λ-kifejezések csak final értékű változókat tudnak hivatkozni.

Vegyünk azonban észre, hogy ez az egész csak akkor működik, hogy előtte megadtuk a CheckPerson interfészt. Ha viszont a szabványos, vagyis a Java alapcsomagjaiban szereplő funkcionális interfészek segítségével dolgozunk, akkor még ezt is megspórolhatjuk. Például a java.util.function.Predicate interfész:

interface Predicate<T> {
    boolean test(T t);
}

felhasználható a CheckPerson kiváltására:

public static void printPersonsWithPredicate(List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

Láthattuk, hogy eddig minden esetben a Person osztály printPerson() metódusát hívtuk meg a feldolgozás végén. Azonban érdemes hozzátenni, hogy a λ-kifejezések segítségével akár ezt is paraméterezhetővé tudjuk tenni. Ehhez mindössze csak egy olyan funkcionális interfészre van szükségünk, ahol egy olyan absztrakt metódus szerepel, ahol egy Person típusú objektum adható át és nincs visszatérési értéke. Ezt például a java.util.function.Consumer interfész le tudja írni:

interface Consumer<T> {
    void accept(T t);
    default Consumer<T> andThen(Consumer<? super T> after);
}

Így a printPerson() hívása lecserélhető a Consumer accept() metódusának hívására:

public static void processPersons(List<Person> roster,
  Predicate<Person> tester, Consumer<Person> block) {
    for (Person p : roster) {
        if (tester.test(p)) {
            block.accept(p);
        }
    }
}

amely aztán így hívható meg:

processPersons(
     roster,
     p -> p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25,
     p -> p.printPerson()
);

Létezik olyan funkcionális interfész is, ahol a λ-kifejezésben szereplő függvénytörzs értéket is hozhat létre. Ezzel tulajdonképpen egy ún. leképezést (mapping) valósítunk meg. Ennek eszköze a java.util.function.Function interfész:

interface Function<T,R> {
    default <V> Function<T,V> andThen(Function<? super R,? extends V> after)
    R apply(T t)
    default <V> Function<V,R> compose(Function<? super V,? extends T> before)
    static <T> Function<T,T> identity()
}

amely alkalmazásával a programunkat ezen a módon alakíthatjuk:

public static <X, Y> void processElements(Iterable<X> source, Predicate<X> tester,
    Function <X, Y> mapper, Consumer<Y> block) {
    for (X p : source) {
        if (tester.test(p)) {
            Y data = mapper.apply(p);
            block.accept(data);
        }
    }
}

Ezzel aztán nagyon könnyen össze tudunk állítani olyan hívásokat, mint amilyen az alábbi:

processElements(
    roster,
    p -> p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25,
    p -> p.getEmailAddress(),
    email -> System.out.println(email)
);

Először végigmegyünk egy adott (felsorolható) gyűjtemény elemein egyesével, amely itt most egy Person objektumokat tartalmazó lista. Az adott predikátum segítségével szűrjük a felsorolt objektumokat, konkrét esetben a személyeket kor szerint. A leképezéssel az egyes objektumokat más típusúakra vetítjük le, például a Person objektumokból csak az e-mail címüket tartjuk meg. Végezetül az eredményül kapott értékeken elvégzünk valamilyen műveletet, amely most itt a kiíratás.

Csővezetékek

Ám ez a logika még ennél is kényelmesebben felírható egyetlen feldolgozási sorozatként, tulajdonképpen egy csővezeték formájában:

roster
  .stream()
  .filter(p -> p.getGender() == Person.Sex.MALE && p.getAge() >= 18 && p.getAge() <= 25)
  .map(p -> p.getEmailAddress())
  .forEach(email -> System.out.println(email));

Ennek során elsőként a stream() metódussal a Person objektumokat tartalmazó, roster nevű listát elemenként felsoroltatjuk. Ez a java.util.stream.Stream interfésszel történik, amely adatfolyamok (stream) ábrázolását teszi elérhetővé. A gyűjteményekkel ellentétben az adatfolyamok nem konkrét adatokat tároló adatszerkezetnek tekinthetőek, hanem egy ilyen csővezetéken folyatnak egymás után keresztül gyűjteményekből származó értékeket. Ezeket a vezetékeket aztán adatfolyam-műveletekből lehet felépíteni, mint amelyek a fenti esetben a filter(), map() és forEach() metódusok voltak. Továbbá ezek a műveletek általában λ-kifejezéseket várnak paraméterként, így működésük finomhangolható. Például a filter() esetében megadható a szűrési feltétel egy predikátummal.

Vannak olyan esetek, amikor nem egyszerűen csak végigfuttatjuk az adatfolyam elemein egy adott csővezeték műveleteit és azokból különálló eredményeket kapunk, hanem azokból együtteséből szeretnénk valamit származtatni. Például vegyük csak Person objektumok listájából csak a férfiakat, és határozzuk meg az átlagos életkorukat!

double average = roster
  .stream()
  .filter(p -> p.getGender() == Person.Sex.MALE)
  .mapToInt(Person::getAge)
  .average()
  .getAsDouble();

A szabványos Java csomagok több ilyen összesítő műveletet is tartalmaznak: average(), sum(), min(), max(), és count(), ezeket redukciós operátoroknak nevezik. De mellettük lehetőségünk van maguknak is ilyen műveleteket megadni a reduce() metóduson keresztül. Például vegyünk most azt a csővezetéket, amely a férfiak életkorának összegét adja meg:

Integer totalAge = roster
  .stream()
  .mapToInt(Person::getAge)
  .sum();

Ugyanez valójában a reduce() alkalmazásával is megadható:

Integer totalAgeReduce = roster
  .stream()
  .map(Person::getAge)
  .reduce(0, (a, b) -> a + b);

Ebből megfigyelhető, a reduce() metódusnak két paramétere van:

  • Egy egységelem, amellyel a redukálást elkezdjük, illetve ezt adjuk vissza akkor, ha nincs feldolgozandó elem.

  • Egy kétparaméteres redukáló függvény, amely megkapja a redukálás részleges értékét, valamint a következő feldolgozandó elemet, és ezekből kiindulva megadja a redukálás következő értékét. Ez tehát egészen addig folytatódik, amíg az adatfolyamban van elem.

Azonban érdemes tudni, hogy a reduce(), valamint a benne alkalmazott redukáló függvény folyamatosan új értékeket számol ki. Amikor összetettebb, nagyobb méretű elemeket tartalmazó gyűjteményeket dolgozunk fel, ez csökkentheti a feldolgozás hatékonyságát.

Ezért ennek a műveletenek létezik egy olyan változata, ahol a redukálás részeredményeit folyamatosan felülírjuk, ez a collect(). Ezzel a korábbi, átlagolós példánkat így tudjuk megfogalmazni:

class Averager implements IntConsumer {
    private int total = 0;
    private int count = 0;

    public double average() {
        return count > 0 ? ((double) total) / count : 0;
    }

    public void accept(int i) { total += i; count++; }
    public void combine(Averager other) {
        total += other.total;
        count += other.count;
    }
}

Averager averageCollect = roster.stream()
  .filter(p -> p.getGender() == Person.Sex.MALE)
  .map(Person::getAge)
  .collect(Averager::new, Averager::accept, Averager::combine);

System.out.println("Average age of male members: " + averageCollect.average());

A collect() műveletnek itt három metódust kap:

  • Ez első feladata, hogy létrehozza azt az objektumot, amely a feldolgozás során tárolja a felülírható állapotot.

  • A második feladata, hogy az adatfolyam egyes elemeit megkapva építse bele azokat az állapotba.

  • A harmadik feladata, hogy két állapotot össze tudjon olvasztani.

De alapvetően a collect() gyűjtemények esetében alkalmazható jobban. Ugyanis a szabványos Java csomagokban található egy java.util.stream.Collectors osztály, ahol rengeteg hasznos előre definiált ilyen feldolgozás van. Például Collectors.toList() metódus az adatfolyam elemeit egy listává alakítja.

List<String> namesOfMaleMembersCollect = roster
  .stream()
  .filter(p -> p.getGender() == Person.Sex.MALE)
  .map(p -> p.getName())
  .collect(Collectors.toList());

A Collectors.groupingBy() metódus az adatfolyam elemeit osztályozza, és azokból a paraméterében megadott művelet eredménye szerint csoportokba szervezi és a végeredményből egy véges leképezést készít.

Map<Person.Sex, List<Person>> byGender = roster
  .stream()
  .collect(Collectors.groupingBy(Person::getGender));

Ez a leképezés magukat a Person objektumokat tárolja el, viszont lehetőségünk van arra, hogy csak egy nézetüket, például a hozzájuk tartozó neveket tároljuk csak el. Ez egy beágyazott leképezéssel valósíthatjuk meg, ahol belül is egy kollektort alkalmazunk.

Map<Person.Sex, List<String>> namesByGender = roster
  .stream()
  .collect(
    Collectors.groupingBy(
      Person::getGender,
      Collectors.mapping(Person::getName, Collectors.toList())));

Másik ilyen lehetőség, amikor a reducing() metódussal az osztályokba korábban besorolt Person objektumokból kiemeljük az életkor és azokat összeadjuk. Ez tulajdonképpen a reduce() kollektor változata.

Map<Person.Sex, Integer> totalAgeByGender = roster
  .stream()
  .collect(
    Collectors.groupingBy(
      Person::getGender,
      Collectors.reducing(0, Person::getAge, Integer::sum)));

Feladatok

  • Vegyük elő a korábban megvalósított Stack<T> és ArrayStack<T> osztályainkat és módosítsuk ezeket úgy, hogy belső osztállyal implementálják a java.lang.Iterable interfészt!

  • Egészítsünk ki úgy a Stack<T> és ArrayStack<T> osztályokat, hogy hibás használat esetén kivételeket váltsanak ki! A különböző hibákra vonatkozó kivételeket hozzuk létre külön, a java.lang.Exception osztály származtatásával!

  • Készítsünk egy programot, amely parancssori paraméterként megkap egy elérési útvonalat és egy kiterjesztést, és ezek, valamint a java.io.File és a java.io.FileFilter osztályok felhasználásával listázza:

    • az elérési útvonalon található, adott kiterjesztésű állományokat!

    • az elérési útvonalon található, adott méretnél nagyobb állományokat!

    • az elérési útvonalon található, adott méretnél nagyobb állományokat az utolsó módosítás dátuma szerint!

  • Bővítsük úgy a programot, hogy a listázáskor megjelenjen az állományok módosítási ideje DDDDDD-HH-MM-SS.ss formátumban (ahol az egyszerűség kedvéért a napokat most nem bontjuk szét évekre és hónapokra, ezeket jelöli a DDDDDD)!

  • Írjunk λ-kifejezéssel egy isNumeric() metódust, amellyel egy String értékről el lehet dönteni, hogy csak számjegyeket tartalmaz!

  • Írjunk λ-kifejezéssel egy isPrime() metódust, amellyel egy egész számról el tudjuk dönteni, hogy prímszám-e!

  • Írjunk λ-kifejezéssel egy stringToInt() metódust, amellyel egy String értéket tudunk adott számrendszerbeli számként beolvasni és a neki megfelelő egész értéket előállítani! Az átalakítás előtt ellenőrizzük, hogy elvégezhető-e a konverzió, és amennyiben nem, váltsunk ki egy java.util.IllegalArgumentException kivételt!

  • Egy csővezeték alkalmazásával készítsünk egy statistics() metódust, ahol kapunk egy String értéket és egy véges leképezésben visszaadjuk, hogy milyen karakterek és azokból mennyi található benne! A véges leképezésben szerepeljenek:

    • a konkrét előfordulások számai,
    • az előfordulások normált (0 és 1 közé eső) értéke, vagyis a leképezésben szereplő karakterek hozzárendelt értékeinek összege 1.
  • Készítsük el az előbbi metódusnak egy olyan változatát mostFrequentChar() néven, ahol csak azt adjuk meg, hogy melyik karakter fordult elő benne a leggyakrabban!

  • Csővezeték segítségével készítsünk egy primes() metódust, amely egy egész számokból álló intervallum alsó és felső korlátait megkapva előállít egy olyan listát, amely abból a prímeket tartalmazza!

Linkek

Kapcsolódó forráskódok
Oktatói honlap
Vissza