Java standard könyvtárak

Fő kategória: Java.

Java Collection API

Áttekintés

Szinte minden programban szükség van adatok memóriában történő tárolására. Erre a tömb elvileg alkalmas, viszont nagyon gyakran ki kell egészíteni ugyanazokkal a műveletekkel: kiolvasás, beszúrás, törlés, annak biztosítása, hogy adott esetben csak egyszer fordulhasson elő egy elem, mindig sorba legyen rendezve stb. Mindezt feladatra szabott hatékonysággal. Mindezt tömbökkel megvalósítani nem lehetetlen, de nagyon sok időt elvesz, a probléma pedig kellően elterjedt ahhoz, hogy számos programozási nyelvben általános megoldások születtek rá. A Java programozási nyelvben ez a Java Collections Framework, melyet magyarra kb. így lehetne fordítani: Java gyűjtemény keretrendszer. Az alábbi ábra ebben a keretrendszerben található interfészeket és osztályokat tartalmazza:

collections.jpg

Lássuk a lényegesebb részeket részletesebben! A Collection egy interfész, ami olyan alapfüggvényeket deklarál, min pl. egy elem beszúrása vagy törlése, elem keresése, a pillanatnyi elemszám, az elemeken való végigiterálás stb., tehát amelyek mindegyik adatszerkezet esetén előfordulnak. Ebből 3 további interfész származik:

  • Set (halmaz): egy elem egyszer fordulhat elő, és alapértelmezésben a sorrend nem garantált. Van azonban egy ebből származó interfész, a SortedSet, amelyben az elemek rendezve vannak. Konkrét megvalósítások: HashSet (általános halmaz), LinkedHashSet (megjegyzi a beszúrás sorrendjét, és garantált a bejárási sorrend), TreeSet (mindig rendezett bináris keresőfa).
  • List (lista): egy elem akárhányszor előfordulhat, és a sorrend adott (de nem feltétlenül rendezett). Konkrét megvalósítások: ArrayList (ha nincs jó okunk mást használni, akkor érdemes ezt választani), Vector (olyan mint az ArrayList, az eljárásai viszont ennek szinkronizáltak, aminek akkor van jelentősége, ha több programszál is használhatja (a többszálúságot ld. a megfelelő fejezetben); a neve amúgy kissé félrevezető), LinkedList (láncolt lista, melyben beszúrás a végére ill. kiolvasás onnan gyors, az általános címzés lassú).
  • Queue (verem): ez egy olyan adatszerkezet, amelyben az elején vagy a végén lehet csak műveleteket végrehajtani: beszúrás, olvasás, törlés. Kétféle megvalósítás van: a LinkedList (ami egyben lista is, ld. fenn) és a PriorityQueue (ahogy a neve is tartalmazza, egy prioritási sorba helyezi az elemeket, pl. a legnagyobb kerül legelőre, azaz a beszúrás sorrendje nem marad meg).
  • Map (asszociatív tömb): ez kulcs-érték párokat tartalmaz, egy kulcsnak egy értéke lehet. Tehát ha egy olyan kulcsnak adunk értet, aminek már volt, akkor az felülíródik. Az első látszat ellenére az egyik leghasznosabb adattípus. Megvalósítások: HashMap (véletlen sorrend), LinkedHashMap (megőrzi a sorrendet), TreeMap (kulcs szerint sorba rendez), Hashtable (szinkronizált függvények).

A Java gyűjtemény keretrendszer generikus típusokat tartalmaz. Pl. egy Stringekből álló listát a következőképpen tudunk létrehozni: List<String> myList = new ArrayList<String>();. A használt osztályok a java.util csomagban találhatóak, amelyek nem importálódnak automatikusan, így a fenti példában a fejlécbe az alábbi sorokat is be kell szúrni: import java.util.List; és import java.util.ArrayList;.

Van még két osztály statikus metódusokkal: Arrays és Collections. Ezek statikus metódusokat tartalmaznak, amelyekkel különböző műveleteket tudunk végrehajtani a tömbökön ill. gyűjtemény osztályokon, pl. a Collections.sort() függvény helyben lerendezi a paraméterül átadott Collection-t. (Ld. a s karaktert az osztálynév végén; a Collection egy interfész, amelyből a Set, a List és a Queue származik, a Collections pedig egy, nagyrészt statikus eljárásokat tartalmazó osztály. Ebből az apró eltérésből gyakran adódik kavarodás.)

A fentiekben használtunk olyan fogalmakat, hogy sorrend meg rendezett. Nézzük meg az alábbi két angol fogalmat, melyeknek a magyar nyelvű jelentése az, hogy rendezett, de két különböző dolgot jelent:

  • Ordered: ez azt jelenti, hogy adott a sorrend, pl. a beszúrás sorrendje, tehát ha végiglépkedünk a struktúra elemein, akkor ugyanabban a sorrendben történik a bejárás, ahogy a beszúrás történt. Ennek ellenkezője az, amikor a sorrend nem garantált. Vonatkozó fogalom még az, hogy egy rendezés (erről később lesz szó) megtartja-e a sorrendet az egyenlő elemek között. (Tegyük fel, hogy van egy osztály, aminek van olyan mezője, amely nem játszik szerepet az összehasonlításnál, azaz ha két objektum csak abban a mezőben tér el egymástól, akkor egyenlőnek tekinthető. Így megkülönböztethető a két osztály. Itt arról van szó, hogy egy rendezés garantálja-e e két elem eredeti sorrendjét vagy nem. Vegyünk egy példát, ahol ennek jelentősége van! Először lerendezzük a fájlokat dátum szerint, majd kiterjesztés szerint. Ha sorrend tartó rendezést használunk, akkor kiterjesztés szerint, azon beül dátum szerint lesznek rendezve a fájlok. Ha a rendezés nem sorrend tartó, akkor a végső rendezés kiterjesztés szerinti lesz, azon belül viszont össze-vissza.)
  • Sorted: ez azt jelenti, hogy az elemek nagyság szerint sorba vannak rendezve, így amikor végiglépkedünk rajta, akkor egy adott rendezési elv szerint először a legkisebbet kapjuk, majd a másodikat, és így tovább. Az egyszerű típusoknál létezik természetes sorrend: pl. számok esetén adott, String esetén az alapértelmezett rendezés a lexikografikus. Azonban az olyan osztályok esetén, ahol több mező van, már nincs természetes sorrend, ott meg kell adni, hogy mi alapján szeretnénk a rendezést végrehajtani. Ezt a Comparable (generikus) interfész megvalósításával tudjuk megadni. Az interfész egy függvényt definiál: compareTo(other), melynek egy paramétere van, a másik elem, amivel az összehasonlítást végezzük. Úgy kell megvalósítani, hogy a visszatérési érték pozitív (általában 1) legyen, ha az adott objektum nagyobbnak számít a paraméterül átadottnál, negatív (általában -1), ha alacsonyabbnak, és 0, ha egyenlőnek.

Lássunk példákat!

Egy egyszerű halmaz

Az alábbi példában létrehozzuk a gyümölcsök halmazát!

import java.util.HashSet;
import java.util.Set;
 
public class Main {
    public static void main(String[] args) {
        Set<String> fruits = new HashSet<String>();
        fruits.add("apple");
        fruits.add("banana");
        fruits.add("peach");
        fruits.add("apple");
        fruits.add("banana");
        fruits.add("apple");
        fruits.add("cherry");
        for (String fruit : fruits) {
            System.out.println(fruit);
        }
    }
}

Figyeljük meg az alábbiakat!

  • A forrás két import utasítással kezdődik.
  • A változó típusának megadásakor egy interfészt adtunk meg (Set). Ez egy jó programozási gyakorlat.
  • A típus után csúcsos zárójelben következik a típus (String).
  • A konkrét típus nem lehet Set, hanem egy adott implementációt kell választanunk. A példában ez a HashSet.
  • A HashSet után csúcsos zárójelben ismét szerepel a típus. Ennek megadása a Java gyűjtemény keretrendszer megalkotásakor kötelező volt, később opcionális lett.
  • A kiírási sorrend nálam a következő: banana, apple, cherry, peach. Látható, hogy mindegyik elem csak egyszer fordul elő, és a sorrend eléggé össze-vissza van: sem a beszúrás sorrendje, sem más egy egyértelmű sorrend (pl. lexikografikus sorrend, a szavak hossza stb.) nem felfedezhető. Ez a halmazműveletek sajátja.

Sorba rendezett halmaz

A fenti példában cseréljük ki a HashSet-et erre: TreeSet (az import utasításban és a példányosításnál is). A kiírási sorrend lexikografikus lesz: apple. banana, cherry, peach. (A TreeSet valójában egy ún. piros-fekete fa.)

Egy egyszerű lista

Listák esetén a beszúrási sorrend garantált, közvetlenül tudunk címezni lekérdezéskor és beszúráskor is (emlékeztetőül: a sorszámozás 0-ról indul), és sorba is tudjuk rendezni. Lássuk az alábbi példát!

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
public class Main {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<String>();
        fruits.add("apple");
        fruits.add("banana");
        fruits.add("peach");
        fruits.add("apple");
        fruits.add("banana");
        fruits.add("apple");
        fruits.add("cherry");
        System.out.println("fruits[2] = " + fruits.get(2));
        fruits.set(1, "grape");
 
        for (String fruit : fruits) {
            System.out.println(fruit);
        }
 
        Collections.sort(fruits);
        System.out.println("Sorted:");
        for (String fruit : fruits) {
            System.out.println(fruit);
        }
    }
}

A struktúra feltöltését követően kiírjuk a tömb második (valójában harmadik, mivel a sorszámozás 0-ról indul) elemét, ami a peach. Utána az első (valójában második) elem értékét erre állítjuk: grape (eredetileg banana volt). Az ezt követő kiíráskor már a grape jelenik meg a banana helyett, egyébként azt tapasztaljuk, hogy mindegyik elem benne maradt (így pl. az apple háromszor), és az eredeti sorrendben történik a kiírás. A listát sorba is tudjuk rendezni, amit az utolsó pár sor illusztrál.

Egy összetett lista példa

Most lássunk egy olyan példát, amelyben egy magunk által létrehozott struktúra példányait helyezzük egy listába, majd lerendezzük:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
public class MyStructure implements Comparable<MyStructure> {
    private int number;
    private String name;
    private String remark;
 
    public MyStructure(int number, String name, String remark) {
        this.number = number;
        this.name = name;
        this.remark = remark;
    }
 
    @Override
    public String toString() {
        return "MyStructure [number=" + number + ", name=" + name + ", remark=" + remark + "]";
    }
 
    public int compareTo(MyStructure other) {
        if (number > other.number) {
            return 1;
        } else if (number < other.number) {
            return -1;
        } else {
            return name.compareTo(other.name);
        }
    }
 
    public static void main(String[] args) {
        List<MyStructure> myList = new ArrayList<MyStructure>();
        myList.add(new MyStructure(2, "apple", "remark1"));
        myList.add(new MyStructure(1, "banana", "remark2"));
        myList.add(new MyStructure(2, "apple", "remark3"));
        myList.add(new MyStructure(2, "banana", "remark4"));
        myList.add(new MyStructure(1, "apple", "remark5"));
        Collections.sort(myList);
        for (MyStructure element: myList) {
            System.out.println(element);
        }
    }
}

Ha lefuttatjuk, az eredmény az alábbi:

MyStructure [number=1, name=apple, remark=remark5]
MyStructure [number=1, name=banana, remark=remark2]
MyStructure [number=2, name=apple, remark=remark1]
MyStructure [number=2, name=apple, remark=remark3]
MyStructure [number=2, name=banana, remark=remark4]

Ez egy kicsit hosszabb és összetettebb példa a szokásosnál; lássuk részletesebben! Az osztály 3 attribútumot tartalmaz: egy számot és két stringet. A konstruktora beállítja mindhárom értéket. A valóságban legalább lekérdező függvényeket meg szoktunk valósítani, most az egyszerűség érdekében ettől eltekintünk. Az osztály felüldefiniálja a toString() metódust, amit a java.lang.Object deklarál, ennek segítségével fogjuk majd kiírni. A compareTo() függvény először a számot hasonlítja össze, egyenlőség esetén az első stringet (name), és ha az is egyenlő, akkor egyenlőnek tekinti a két objektumot, a második string (remark) nem játszik szerepet az összehasonlításban. Végül a főprogramban létrehoz egy MyStructure típusú elemeket tartalmazó listát, behelyez 5 elemet, melyek közül kettő logikailag egyenlő, és lerendezi azokat. A rendezés megtartja az eredeti sorrendet. AZ eredmény azt, amit vártunk: először szám szerint rendez, utána lexikografikusan.

Asszociatív tömb

Amint arról szó volt, az asszociatív tömb kulcs-érték párokat tartalmaz. Lássunk egy példát!

import java.util.HashMap;
import java.util.Map;
 
public class HashMapExample {
 
    public static void main(String[] args) {
        Map<String, Integer> myMap = new HashMap<String, Integer>();
        myMap.put("apple", 4);
        myMap.put("banana", 3);
        myMap.put("peach", 4);
        myMap.put("banana", 5);
        myMap.put("cherry", 2);
        System.out.println(myMap.get("banana"));
    }
 
}

Ha egy kulcsnak már van értéke, akkor az felülíródik. Így a fenti program eredménye 5 lesz.

equals() és hashCode()

A java.lang.Object deklarál két olyan függvényt, melyek ugyan függetlenek a Java gyűjtemény keretrendszertől, viszont igazi jelentőségüket itt kapják; ezek az equals() és a hashCode().

A boolean equals(Object obj) függvényt úgy kell megvalósítani, hogy a visszatérési értéke igaz (true) legyen, ha a paraméterül kapott objektum logikailag megegyezik az adott objektummal, és hamis (false) különben. A jelentőségének megértéséhez a következőket vegyük figyelembe:

  • Az alapértelmezett megvalósítás olyan, hogy a két objektum memóriacímét hasonlítja össze. Lehet, hogy a két objektum összes mezője megegyezik, viszont az egyenlőség viszgálat lehet, hogy hamis értékkel tér vissza.
  • Az előző állításban nem véletlen a bizonytalanság. Ugyanis a Java virtuális gép fel van készítve olyan memóriaoptimalizálásra, hogy ha egy nem megváltoztatható osztályból olyan példányt hozunk létre, ami már létezik, akkor nem foglal le újabb memóriaterületet, hanem a már létezőre állítja a hivatkozást. EZ különösen gyakori a Stringek esetén. Így egy ugyanolyan tartalmú, de külön létrehozott két osztálypéldány összehasonlításának az eredménye nem definiált.
  • A leggyakoribb megvalósítás olyan, hogy sorba veszi az az attribútumokat, és ha mindegyik megegyezik, akkor igazzal tér vissza, ha nem, akkor hamissal. Jogosan vetődik fel a kérdés, hogy miért nem ez az alapértelmezett megvalósítás; A Scalaban például az alapértelmezett megvalósítás nem a memóriacímeket, hanem a tartalmakat hasonlítja össze. Pár dolgot azonban érdemes figyelembe venni a megvalósításnál.
    • Objektumok összehasonlítása során nem az ==-t használjuk, hanem az equals()-t, különben ugyanazzal a problémával szembesülünk, mint az eredeti esetben: tartalom helyet cím összehasonlítás. Persze lehet, hogy arra vagyunk kíváncsiak, hogy fizikailag ugyanarra a memóriacímre mutat-e a két hivatkozás.
    • Gyűjtemények összehasonlítása esetén figyeljünk arra, hogy hogyan szeretnénk végrehajtani az összehasonlítást: ugyanarra a memóriaterületre hivatkozik-e a két referencia, ugyanazokat az elemeket tartalmazza-e; ha a sorrend is számít, akkor ugyanabban a sorrendben vannak-e; számít-e a típus (pl. egyenlőnek számít-e egy ArrayList és egy Vector, ha egyébként ugyanazokat az elemeket tartalmazzák, ugyanabban a sorrendben).
    • Elképzelhető, hogy az összehasonlítás során nem szeretnénk minden mezőt felhasználni. Például elképzelhető, hogy az osztály tartalmaz egy megjegyzés mezőt, és ha az osztály két példánya csak ebben tér el egymástól, akkor azt egyenlőnek tekintjük. Ez is figyelembe kell venni a megvalósításnál.

Az int hashCode() függvény (hasító függvény) jelentőségét a hasító táblák megismerésével érthetjük meg (TODO: hivatkozás az Algoritmusok és adatszerkezetek oldalra, ha kész lesz). Röviden összefoglalva: képzeljünk el sorszámozott vödröket. Ez a függvény valójában azt a számot adja vissza, hogy ha az adott objektumot bele kellene tenni valamelyik vödörbe, akkor melyikbe kerüljön. Ennek akkor van jelentősége, hogy ha meg szeretnénk keresni, akkor melyik vödörben keressük. A vödörben viszont nincs rend, így érdemes olyan sok vödröt felállítani, hogy egy vödörbe várhatóan ne kerüljön túl sok elem. Abszolút ideális esetben pont annyi vödör van, ahány elemszám, és mindegyik külön vödörbe kerül. Ezt elérni lényegében lehetetlen, törekedni viszont lehet rá. A leggyakoribb hiba az az, hogy sok elem ugyanazt a vödröt jelöli meg, és sok vödör üresen marad.

A számmal kapcsolatban egyetlen megkötés van csak: a logikailag egyenlőnek tekintett objektumoknak ugyanaz legyen. Elméletben az is helyes, ha mindegyik objektum ugyanazt a számot adja vissza; ez esetben mindegyik ugyanabba a vödörbe kerül, és egy idő után nehéz lesz bármit megtalálni. Ráadásul a jó hasító függvény igyekszik a hasonló értékű objektumoknak jelentősen eltérő értéket adni.

A jó hashCode() függvény megértéséhez lássunk egy való életből vett példát: képzeljünk el egy könyvtárat! Tegyük fel, hogy a vödrök itt a polcok, mindegyik egy számmal van betűvel van megjelölve,a polcon belül viszont összevissza vannak a könyvek. Az első ötletünk az, hogy a hasító függvény képződjön a könyv címének első betűjében: 1, ha A, 2 ha Á és így tovább. A probléma az, hogy az 1-es sorszámú polc roskadozni fog a könyvektől, így ott elég nehéz lesz bármit is megtalálni, míg mondjuk a Q betű sorszámát tartalmazó polc kongani fog az ürességtől. Azon túl, hogy célszerű több részre osztani, érdemes a fentinél bonyolultabb megoldást találni, pl. összeadni a könyv címében szereplő összes betű sorszámát, elosztani az összes lehetséges értékek számával, és venni az osztás maradékát. Ebben a példában láthatjuk, hogy miért van jelentősége annak, hogy az egyenlőnek tekintett objektumok miért kapjanak ugyanolyan hasító értéket: pl. ha a könyv ISBN számát is figyelembe vennénk, akkor ugyanannak a könyvnek két külön kiadása külön polcra kerülne.

Lényeges elvárás a hashCode() függvénnyel kapcsolatban az, hogy gyors legyen. Ennek a jelentősége a következő: az equals() sor esetben lassú (tegyük fel, hogy sok attribútumot tartalmaznak az összehasonlítandó objektumok, melyek közül lehet drága is, pl. két lista összehasonlítása), viszont a legtöbb esetben az összehasonlítandó elemek különböznek. Tegyük fel, hogy nagyon sok összehasonlítást kell egymás után végrehajtani (pl. rendezéskor). EZ esetben célszerű előbb a könnyen kiszámolható és könnyen összehasonlítható hashCode() értékeket összehasonlítani, és azok egyezősége esetén végrehajtani csak a éyleges összehasonlítást. (És itt van jelentősége annak, hogy hasonló, de nem egyenlő elemek kapjanak különböző értéket, lehetőleg egymástól távolit.)

Minden olyan adatszerkezetben jelentősége van a hasító függvénynek, melyben szerepel a Hash előtag (HashMap, Hashtable, HashSet, …). Az egyenlőségvizsgálattal kapocslatos megkötés miatt többnyire együtt szoktuk megvalósítani e két függvényt. Ha a már említett Comparable interfészt is megvalósítjuk, akkor érdemes ezt a kettőt is megvalósítani, legalább amiatt, hogy a compareTo() és az equals() konzisztens legyen.

Lássunk egy példát!

import java.util.HashSet;
import java.util.Set;
 
public class MyStructure {
    private int number;
    private String name;
    private String remark;
 
    public MyStructure(int number, String name, String remark) {
        this.number = number;
        this.name = name;
        this.remark = remark;
    }
 
    @Override
    public String toString() {
        return "MyStructure [number=" + number + ", name=" + name + ", remark=" + remark + "]";
    }
 
    @Override
    public int hashCode() {
        System.out.println("hashCode() called");
        final int prime = 31;
        int result = 1;
        result = prime * result + ((name == null) ? 0 : name.hashCode());
        result = prime * result + number;
        return result;
    }
 
    @Override
    public boolean equals(Object obj) {
        System.out.println("equals() called for " + this + " and " + obj);
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        MyStructure other = (MyStructure) obj;
        if (name == null) {
            if (other.name != null)
                return false;
        } else if (!name.equals(other.name))
            return false;
        if (number != other.number)
            return false;
        return true;
    }
 
    public static void main(String[] args) {
        Set<MyStructure> mySet = new HashSet<MyStructure>();
        mySet.add(new MyStructure(2, "apple", "remark1"));
        mySet.add(new MyStructure(1, "banana", "remark2"));
        mySet.add(new MyStructure(2, "apple", "remark3"));
        mySet.add(new MyStructure(2, "banana", "remark4"));
        mySet.add(new MyStructure(1, "apple", "remark5"));
        for (MyStructure element: mySet) {
            System.out.println(element);
        }
    }
}

A példa sok mindenben megegyezik a fentivel, van viszont pár eltérés:

  • Nem listát, hanem nem sorba rendezhető halmazt hozunk létre, így nincs jelentősége a Comparable interfész megvalósításának. Nincs nagy jelentősége, de az egyszerűség érdekében kivettem.
  • A halmaz konkrét megvalósulása a HashSet, így jelentősége van a hashCode() függvénynek.
  • Az equals() és a hashCode() csak a number és a name értékét veszi figyelembe, a remark-ot nem. Ilyen értelemben kompatibilis az eredeti, compareTo() függvényt is tartalmazó megvalósítással.
  • E két függvényt valójában az Eclipse-ben található Source → Generate hashCode() and equals()… segítéségével hoztam létre. AZ első, kiíró sorokat kézzel adtam hozzá.
  • A futás eredménye az alábbi. Ebből látszik, hogy a hashCode() és az equals() függvény is meghívódott, és az eredményből hiányzik a remark3, mert az logikailag egegyezik a remark1-gyel.
hashCode() called
hashCode() called
hashCode() called
equals() called for MyStructure [number=2, name=apple, remark=remark3] and MyStructure [number=2, name=apple, remark=remark1]
hashCode() called
hashCode() called
MyStructure [number=1, name=banana, remark=remark2]
MyStructure [number=2, name=banana, remark=remark4]
MyStructure [number=1, name=apple, remark=remark5]
MyStructure [number=2, name=apple, remark=remark1]

Műveletek az adatszerkezeteken

A példákban elemek hozzáadását láttuk, de természetesen számos egyéb műveletet is végre tudunk hajtani az adatstruktúrákon. Érdemes tudni, hogy melyik művelet hol van deklarálva, annak érdekében, hogy tudjuk, mit hol tudunk végrehajtani.

Collection: ezek a műveletek tehát közösek a listák, a halmazok és a sorok esetén. Noha ugyanaz a függvények szignatúrája minden esetben, az egyes műveletek futási ideje jelentősen különbözhet különböző konkrét megvalósulások esetén; ezt mindenképpen figyelembe kell venni, amikor adott adatstruktúra mellett döntünk.

  • add(elem) és addAll(collection): hozzáad egy elemet ill. egy egész gyűjteményt.
  • clear(): törli az elemeket.
  • contains(elem) és containsAll(collection): azt adja vissza, hogy a gyűjtemény tartalmazza-e a paraméterül átadott elemet vagy elemek mindegyikét.
  • boolean isEmpty(): visszaadja, hogy üres-e az adott gyűjtemény.
  • iterator(): egy iterátorral tér vissza, melynek segítségével végig tudunk lépegetni a gyűjtemény elemein, az iterátor boolean hasNext() és next() függvényeinek segítségével.
  • remove(element) és removeAll(collection): törli a paraméterül átadott elemet ill. elemeket.
  • retainAll(collection): a paraméterül átadott gyűjteménnyel való metszetét adja vissza (nincs minden esetben megvalósítva).
  • int size(): a gyűjteményben található elemek számát adja vissza.
  • toArray(): tömbként adja vissza a gyűjtemény elemeit.

A forEach(), a removeIf és a stream() függvények a Java 1.8-as újításaihoz kapcsolódnak, melyekről a megfelelő alfejezetekben lesz szó.

List: a listák setén értelmezhető a sorrend, például mindegyik elemnek van egy egyértelmű indexe (ez pl. halmazok esetén nem így van). A listán értelmezett további függvények:

  • get(index) és set(index, element): lekérdezi ill. beállítja az adott indexű elemet.
  • indexOf(element): visszaadja a paraméterül átadott elem indexét.
  • sort(comparator): a paraméterül átadott összehasonlító segítségével helyben lerendezi a listát.
  • subList(fromIndex, toIndex): a megfelelő allistával tér vissza.

Set: a halmazok esetén nincs olyan művelet, ami ne lenne benne az alap Collection interfészben.

Queue: vannak külön beszúró és kiolvasó műveletek:

  • offer(element): beszúrás
  • peek(): visszaadja, de nem törli az első elemet.
  • poll(): visszaadja és egyúttal törölni az első elemet.

Map: a kulcs-érték párok kezelésével kapocslatos függvények vannak ebben benne. Ez nem származik a Collection-ből, de tartalmazza az alábbiakat: clear(), isEmpty(), size().

  • put(key, value): adott kulcs-érték párt helyez az asszociatív tömbbe.
  • get(key): visszatér az adott kulcshoz tartozó értékkel.
  • keySet(): visszaadja az összes kulcsot.
  • values(): visszaadja az összes értéket.
  • containsKey(key) és containsValue(value): megmondja, hogy az adott kulcs vagy érték benne van-e az asszociatív tömbben.

A fenti függvényeknek vannak egyéb formái is, ill. más egyéb függvények, melyek bizonyos esetekben hasznosak lehetnek.

Algoritmusok

Az adatszerkezetek és az algoritmusok kéz a kézben járnak. Amint arról már szó volt, létezik egy java.util.Collections nevű osztály (ami - ismétlem - nem összetévesztendő a java.util.Collection interfésszel), ami a leggyakoribb algoritmusok megvalósításait tartalmazza. Lássunk pár példát:

  • Collections.sort(collection): helyben sorba rendezi a paraméterül átadott collection gyűjteményt. A rendezés sorrend tartó, azaz az egyenlő elemek ugyanabban a sorrendben maradnak, amilyenben voltak eredetileg. Erre már láttunk példát a gyümölcsök sorba rendezésénél.
  • Collections.binarySearch(list, element): megkeresi az element elemet a list listában. A paraméternek tehát itt listának kell lennie, ráadásul rendezettnek. A keresés az adatszerkezetekben egy igen gyakori művelet. Egy nyilvánvaló megvalósítása az, hogy végiglépkedünk az elemeken, és mindegyiknél elvégezzük az összehasonlítást, hogy az-e a keresett elem. Ez jól működik akkor, ha nincs túl sok elem az adatszerkezetben, de sok elem esetén már problémás, különösen akkor, ha gyakran végre kell hajtani. Ha rendezve vannak az elemek, akkor van egy gyors módszer: megnézzük, hogy a felénél kisebb vagy nagyobb, a megfelelő felét ismét elfelezzük, és így tovább. Ezer elemnél 10, egymillió elemnél 20, egymilliárd elemnél 30 összehasonlítást kell elvégezni. A megvalósítás tehát nem túl bonyolult, de sok apróságra figyelni kell, pl. a határokra (pl. ha a legnagyobb elemnél is nagyobb értékre keresünk); a binarySearch() függvény pont ezt valósítja meg. És a módszerből jól látható, hogy miért kell rendezettnek lennie. A visszatérési érték a megfelelő elem indexe, ill. ha nincs benne, akkor egy negatív szám, melynek az abszolút értéke az az érték, ahova be kellene szúrni az elemet.
  • boolean Collections.disjoint(collection1, collection2): megállapítja, hogy a paraméterül átadott két gyűjtemény diszjunkt-e, azaz van-e közös elemük (ekkor nem diszjunkt, azaz a visszatérési értéke false).
  • Collections.copy(dest, source): a source listából bemásolja az elemeket a dest listába (felülírva a dest-ben már benne levő megfelelő hosszúságú elemeket).
  • Collections.fill(list, element): a listát feltölti a megadott elemekkel.
  • int Collections frequency(collection, element): a paraméterül átadott eleent elem előfordulási számát adja vissza a collection gyűjteményben.
  • int Collections.indexOfSublist(source, target) és int Collections.lastIndexOfSublist(source, target): a source listában megkeresi a target allistát, és annak az első ill. utolsó előfordulását adja vissza. Ezt tehát pl. szókeresésre lehet használni, ha a lista elemei betűk.
  • Collections.max(collection) és Collections.min(collection): a gyűjtemény legnagyobb ill. legkisebb értékét adja vissza. A gyűjtemény elemeinek értelemszerűen összehasonlíthatónak kell lenniük, vagy az összehasonlítót paraméterül át kell adni.
  • Collections.replaceAll(list, oldValue, newValue): a listában kicseréli az összes oldaValue elemet newValue-ra.
  • Collections.reverse(list): megfordítja a paraméterül átadott lista sorrendjét.
  • Collections.rotate(list, distance): körbe forgatja a lista elemeit distance lépéssel.
  • Collections.suffle(list): véletlenszerűen összekeveri a lista elemeit.

Még számos egyéb függvény definiált, így mielőtt megvalósítunk egy algoritmust, érdemes megnézni, hogy az meg van-e már valósítva.

Az Arrays a Collections-höz hasonló függvényeket tartalmaz, a paraméter viszont nem valamilyen Collection, hanem tömb.

String

A Stringről már volt szó: a főprogram paraméterlistája String tömbként kapja meg az átadott paramétereket, így már a legegyszerűbb Java program esetén is megkerülhetetlen, ugyanakkor - noha nem primitív típus - annyira alapvető, hogy elkerülhetetlen a használata már kezdettől fogva. Most egy picit részletesebben megnézzük! Az egyszerűség érdekében mostantól elhagyjuk az osztály- és függvénydefiníciót; a már bemutatott példák segítségével tudunk fordítható és futtatható kódot készíteni.

String létrehozása

A String valójában nem más, mint karakterek (char) egymásutánja. Tehát alapértelmezésben a következőképpen hozzuk azt létre:

char[] szia = {'s', 'z', 'i', 'a'};
String sziaStr = new String(szia);

Ilyen a valóságban a legritkább esetben teszünk, a tipikus inkább a következő:

String hello = "hello";

Már volt róla szó, de ismétlésként álljon itt ismét: a Java-ban kettő vagy több Stringet a + operátorral tudunk egymás után fűzi, pl.:

System.out.println(hello + " world");

Szerintem sajnos elég szerencsétlen döntésnek bizonyult a + jel használata, ugyanis ugyanezt használjuk a "rendes" összeadásnál. Nézzük a következő példát!

System.out.println(2 + 3);
System.out.println("" + 2 + 3);

Az első sor eredménye 5 lesz, mert először elvégzi az összeadást, utána pedig kiírja az eredményt. A második viszont 23, mert eleve (üres) stringgel indul, ahhoz hozzáfűzi a 2-t, ami egész, de automatikusan stringgé konvertálja, majd ugyanígy a 3-at is.

Egyenlőség vizsgálat

Az egyenlőség vizsgálat előtt érdemes kicsit kitérni arra, hogy hogyan tárolja a Java a stringet a memóriában. Az egész módszer mögött az a felismerés van, hogy már egy közepes méretű programban is igen sok szöveg képződik, melyeknél nagyon sok az ismétlődés. Ha például az egész számok 1 és 100 között sokszor előfordulnak, akkor érdemes azokat eleve eltárolni, majd csak rá hivatkozni. Így ha kétszázszor előfordul a programban mondjuk az 52, akkor elég mindegyik esetben az "előre gyártottra" hivatkozni. Ez persze azt is jelenti, hogy megváltoztatni nem tudjuk; egészen pontosan ha változtatunk rajta, akkor az valójában a háttérben vagy áthivatkozást jelent (ha már előfordul), vagy új memóriafoglalást (ha még nincs ilyen). Nos, a Java pont ezt a módszert alkalmazza: ha keletkezik egy újabb string, megnézi, hogy van-e már olyan a memóriában és ha talál, akkor rá hivatkozik; ha nem, akkor újat hoz létre. Persze tökéletes megoldást csak úgy lehetne létrehozni, ha minden egyes kis string esetén "végignyálazná" a teljes memóriát, ami nyilván borzasztó hatékonytalan lenne, így elfordulhat, hogy mégis új memóriahelyet foglal le, noha már létezik.

A programozó az összehasonlításhoz ösztönösen a == operátort használja, a Java esetén viszont String összehasonlításnál ez nem jó, és sajnos nagyon nehezen felderíthető hibákhoz vezethet. Ez ugyanis primitív esetben az értékeket hasonlítja össze, osztályok esetén viszont magát a hivatkozást! Az, hogy két egyelnő tartalmú string esetén a hivatkozás ugyanarra a memóriacímre mutat-e, nem egyértelmű. Így az egyik fenti példát folytatva a hello == "hello" eredménye nem megjósolható. Általában igaz, de előfordulhat, hogy nem. Ráadásul feltételezhető, hogy ez Java verzió függő is; remélhetőleg a fenti egyenlőség vizsgálat a Java verziószám növekedésével nagyobb eséllyel ad igazt mint hamist.

Viszont erre a bizonytalanságra nem alapozhatunk! Emiatt a stringek összehasonlítását a Java-ban mindenképpen az equals() függvény segítségével érdemes végrehajtanunk, és a fenti példában a hello.equals("hello") garantáltan igaz értékkel tér vissza.

Itt érdemes megemlíteni egy ún. best practice-t: az említett összehasonlítást valójában nem a fenti formában szokás végrehajtani, hanem fordítva, így: "hello".equals(hello). Ennek az oka az, hogy ha a hello változó értéke null, akkor az első NullPointerException-nel elszáll, a második pedig hamis értékkel tér issza, de a program folytatódik. Személy szerint egyébként ezzel nem értek egyet: egyrészt olvashatóság szempontjából az első megközelítés természetesebb, másrészt az eleve egy elhibázott koncepció, ha a null érték összehasonlításkor ténylegesen előfordulhat; ha a hello értéke null, akkor szerintem inkább szálljon el a program mint adjon vissza egy - valószínűleg - hibás hamis értéket, elnyomva ezzel egy potenciális hibát. Ha a program elszáll, akkor a programozó megkeresi a hibát, ill. átszervezi, hogy azon a ponton már elő se fordulhasson a null érték. Annak a tesztelése ugyanis, hogy az adat hiányzik-e,egész más dimenzió, mint az, hogy az adat rendelkezésre áll és adott értéket tartalmaz-e.

String műveletek

A stringeken számos műveletet hajthatunk végre. Az összefűzést a + operátorral már említettük, most vizsgáljuk meg egy adott stringen végrehajtható függvényeket! A példában a hello egy String.

  • hello.length(): visszaadja a string hosszát.
  • hello.toUpperCase(): visszaadja a string nagybetűs változatát. (Tehát nem helyben módosítja.)
  • hello.charAt(2): visszaadja a 2. karakter, 0-tól sorszámozva; a példában így l lesz az eredmény.
  • "apple:peach:banana".split(":"): felbontja a stringet részekre; a példában az eredmény egy 3 elemű string tömb lesz (String[]).

StringBuffer és StringBuilder

Amint azt láttuk az egyenlőség vizsgálatban, a String memóriakezelése igen sajátságos. A stringek nem módosíthatóak, és a + operátorral össze lehet őket fűzni. Ez persze azt is jelenti, hogy a "hello" + " " + "world" utasítás hatására valójában 4 string jön létre: a felsorolt 3, valamint külön az eredmény. Valójában tehát igen pazarló a stringek egymás után fűzése, csak egyszerű esetekben célszerű használni. A stringek összefűzése viszont igen gyakori művelet; erre alkották meg a StringBuffer osztályt, melynek működését egy példán illusztráljuk:

StringBuffer buffer = new StringBuffer();
buffer.append("hello");
buffer.append(" ");
buffer.append("world");
System.out.println(buffer.toString());

Ez tehát képes módosítani helyben a stringet, egészen pontosan azt a struktúrát, amiből a végén string keletkezik.

A StringBuffer szálbiztos, ami azt jelenti, hogy többszálú program esetén is biztonsággal használhatjuk, a szálak nem fognak "összeakadni". (A többszálúságról később lesz szó részletesebben.) A szálbiztosságnak viszont ára is van: lassúbb mintha nem lenne szálbiztos. Mivel az esetek döntő többségében a StringBuffer esetén valójában nincs szükség szálbiztosságra, a Java 1.5-ben bevezették a StringBuilder osztályt, ami azon túl, hogy nem szálbiztos, teljes egészében megegyezik a StringBuffer-rel. Ha tehát biztosak vagyunk abban, hogy a StringBuilder-t olyan függvényből használjuk, amit csak egyetlen szál hívhat, akkor érdemes inkább StringBuilder-t használni.

toString()

A toString() függvényt az Object deklarálja, így tetszőleges objektumot stringgé tudunk alakítani. Ez azt is jelenti, hogy a System.out.println(o) függvénynek tetszőleges objektumot átadhatunk, az implicit módon meghívja a toString() metódust, és valamit mindenképp ki fog írni. Az Object osztályban az alapértelmezett megvalósítás szerint az eredmény az objektum referenciája lesz. A legtöbb esetben ezt felüldefiniálják, pl. a StringBuffer esetén a tartalmazott szöveg string formátuma lesz az eredmény. A saját osztályok esetén is célszerű felüldefiniálni ezt a függvényt, különösen akkor, ha az osztályunk valójában egy adatstruktúra, hogy ne (az egyébként semmitmondó) referenciát írja ki, hanem a tartalmat. Erre példát láthattunk a fenti MyStructure példában.

Reguláris kifejezések

A string műveletekhez szorosan kapcsolódik a reguláris kifejezések (regular expression) fogalma. Az ezzel kapcsolatos osztályok a java.util.regex csomagban találhatóak. Például a Pattern.matches("^ab+c$", "abbbbc") első paramétere a reguláris kifejezés, a második pedig a szöveg, amit ellenőrizni szeretnénk, a visszatérési értéke pedig az, hogy a szöveg megfelel-e a reguláris kifejezésnek.

A reguláris kifejezésekről a Szabványok oldalon olvashatunk bővebben.

Matematika

A Math osztály tartalmaz statikus matematikai függvényeket (melyek a szó matematikai értelmében is valóban függvények), ráadásul mivel ez a java.lang csomagban található, még importálni sem kell hozzá semmit. Felesleges lenne felsorolni minden függvényt, csak néhány emelek ki ízelítőül:

  • Négyzetgyök: Math.sqrt(x) (az angol square root rövidítése)
  • Trigonometrikus függvények: Math.sin(x), Math.cos(x), Math.tan(x), Math.asin(x) stb.
  • Abszolút érték függvények: Math.abs(x), különböző típusú paraméterekkel.
  • Kerekítések: Math.round(x) (matematikai értelemeben vett kerekítés), Math.floor(x) (lefele kerekítés), Math.ceil(x)): felfele kerekítés.
  • Hatványozás: Math.pow(x, y)
  • Logaritmus: Math.log(x) (természetes alapú logaritmus), Math.log10(x) (tízes alapú logaritmus)
  • Véletlen szám generátor: Math.random() (a java.util.Random osztályt érdemes használni megismételhető véletlen szám generáláshoz, példányosítás után setSeed(), majd pl. nextInt()).

Ugyanez definiálja még az alábbi konstansokat: Math.PI és Math.E, értelemszerűen.

A következő példa a négyzetgyökvonást és a kerekítést illusztrálja. Ez utóbbira amiatt van szükség, mert a négyzetgyök paramétere és eredménye is lebegőpontos (egészen pontosan double), így ha egészként szeretnénk kiírni az eredményt (tehát nem 3.0-t, hanem 3-at), akkor a Math.round(x) függvény egy megfelelő választás.

public class MathExample {
    public static void main(String[] args) {
        System.out.println("√9=" + Math.round(Math.sqrt(9)));
    }
}

A rendszer elérése

A bevezető példában láthattuk azt, hogy kiírni a System.out.println() rendszerfüggvény segítségével lehet. A kiírás tehát nem nyelv alapelem, ahhoz beépített osztályra van szükség. A System osztály segítségével magát a rendszert érhetjük el. Most nézzük meg ezt egy kicsit részletesebben!

  • A System 3 publikus, statikus attribútumot definiál: out - standard output, err - standard error, in - standard input. Amikor tehát a System.out.println() függvényt hívíjuk meg, akkor a standard outputon hívjuk az ugyancsak statikus println() függvényt.
  • A System.exit() segítségével azonnal ki tudunk lépni a Java programból; paraméterül a visszatérési értéket kell megadnunk, amit az operációs rendszer kap meg (pl. System.exit(-1);). Egyébként ez az egyetlen programozható eset, amikor a try-catch-finally során nem fut le a finally. A gyakorlatban célszerű kerülni a használatát.
  • A környezeti változókat tudjuk beolvasni ill. módosítani. Pl. a System.getenv() az összes környezeti változóval tér vissza kulcs-érték formában. A System.getenv(key) eg adott környezeti változót kérdez le. A System.getProperties() a programnak -D-vel átadott értékeket olvassa be, a System.getProperty(key) egy adott értéket, a System.getProperty(key, default) pedig megvizsgálja, hogy az adott érték be van-e állítva, és ha igen, azzal tér vissza, ha nem, egy alapértelmezett értékkel. Ez utóbbi típust módosíthatjuk ill. törölhetjük is a System.setProperty(key, value), a System.setProperties(properties) és a System.clearPorperty(key) függvényekkel.
  • Lekérdezhetjük a rendszeridőt a System.currentTimeMillis() ill. a System.nanoTime() függvényhívásokkal.
  • Javasolhatjuk a Java virtuális gépnek, hogy szabadítson fel memóriát, a System.gc() hívással. Vonatkozó eljárás a System.runFinalization().
  • Lekérdezhetjük a sorvégi karaktereket (tehát hogy '\n' vagy '\r\n'-e) a System.lineSeparator() függvényhívással.
  • A System.console() eljárással lekérhetjük a konzolt (ha van), és ott író-olvasó műveleteket hajthatunk végre. Pl. ily módon célszerű jelszót beolvasni.

És még számos egyéb eljárást találunk a System osztályban.

Fájlkezelés

Beolvasás fájlból kezdetben

A fájlkezelés a Java-ban hosszú utat járt be, és ez egy jó példa arra, hogy hogyan lehet valamit nagyon elrontani. Kezdetben létrehoztak egy rendkívül elbonyolított InputStream és OutputStream hierarchiát, és erre építették rá a fájlműveleteket is. Csak érdekességképpen említem meg, hogy kezdetben hogyan lehetett ezt megoldani:

import java.io.*;
import java.nio.charset.Charset;
 
class FileInputExample {
    public static void main(String[] args) {
        BufferedReader dis = null;
        String line = null;
        try {
            File file = new File("mydata.txt");
            FileInputStream fis = new FileInputStream(file);
            BufferedInputStream bis = new BufferedInputStream(fis);
            InputStreamReader isr = new InputStreamReader(bis, Charset.defaultCharset());
            dis = new BufferedReader(isr);
            while ((line = dis.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (dis != null) {
                try {
                    dis.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
        }
    }
}

Tehát szükség volt a következőkre: File, FileInputStream, BufferedInputStream, InputStreamReader, kétszintű hibakezelés, hogy csak az alap példa problémáit említsem. Ezzel valójában két probléma volt:

  • Lehetetlen volt megjegyezni, minden egyes alkalommal rá kellett keresni.
  • Szinte sikított a változásért, ami be is következett, viszont ezzel még komplikáltabb lett a rendszer. A felülről kompatibilitás kényszere miatt ugyanis a régi rendszer megmaradt (ráadásul részben depricated lett, részben nem; tehát az eredeti példa ráadásul nem is pont így nézett ki mint a fent megadott), és megjelent az új, egyszerűsített változat is.
  • Ugyanakkor idővel további javításokat tettek bele, ami egyrészt jó, másrészt tovább fokozta a kuszaságot.

Ha valaki el szeretne mélyedni az input és output stream-ek világában, annak ajánlom elrettentésül az ezen az oldalon található táblázatot: http://tutorials.jenkov.com/java-io/overview.html, a kapcsolódó oldalakat, valamint a https://www.javatpoint.com/java-io oldalt.

Kiírás fájlba kezdetben

A kezdeti fájlba írás is eléggé komplikált (ráadásul hibásan működik, legalábbis nálam):

import java.io.*;
 
class FileInputExample {
    public static void main(String[] args) throws IOException {
        FileOutputStream fos = new FileOutputStream("test.txt");
        BufferedOutputStream bos = new BufferedOutputStream(fos);
        DataOutputStream outStream = new DataOutputStream(bos);
        outStream.writeUTF("Hello");
        outStream.writeUTF(" world");
        outStream.close();
    }
}

Fájlból olvasás a Scanner osztály segítségével

A Scanner osztály a Java 1.5-ben jelent meg, és jelentős mértékben leegyszerűsítette a beolvasást:

import java.io.*;
import java.util.Scanner;
 
class FileInputExample {
    public static void main(String[] args) throws IOException {
        File file = new File("mydata.txt");
        Scanner sc = new Scanner(file);
        while (sc.hasNextLine()) {
            System.out.println(sc.nextLine());
        }
        sc.close();
    }

Sőt, ha beállítjuk, hogy az elválasztó karakter az alapértelmezett új sor helyett a fájl vége legyen, akkor egyből be tudjuk olvasni, ciklus nélkül:

import java.io.*;
import java.util.Scanner;
 
class FileInputExample {
    public static void main(String[] args) throws IOException {
        File file = new File("mydata.txt");
        Scanner sc = new Scanner(file);
        sc.useDelimiter("\\Z");
        System.out.println(sc.next());
        sc.close();
    }
}

Kiírás a PrintWriter osztály segítségével

A FileWriter és a PrintWriter osztályok használata valamelyest egyszerűsíti a kiírást, ráadásul a lehetőségeket is javítja:

import java.io.*;
 
class FileInputExample {
    public static void main(String[] args) throws IOException {
        FileWriter fileWriter = new FileWriter("test.txt");
        PrintWriter printWriter = new PrintWriter(fileWriter);
        printWriter.println("Hello world");
        printWriter.printf("This is a text: %s, and this is an integer: %d.", "apple", 5);
        printWriter.close();
    }
}

A Files osztály használata fájlműveletekre

import java.io.*;
import java.nio.file.*;
 
class FileInputExample {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("test.txt");
        Files.write(path, "Hello".getBytes());
        System.out.println(Files.readAllLines(path));
    }
}

Ennek is vannak nehézségei (a Path szükségessége ahelyett, hogy elég lenne megadni a fájlnevet; Stringet nem tudunk kiírni, csak bájtokat), viszont az eredmény kompakt, ugyanaz az osztály használható kiírásra és beolvasásra is, ráadásul a Files számos egyéb fájlműveletet definiál: fájl és könyvtár létrehozása, létezésnek ellenőrzése, másolás, törlés stb.

Egyéb fájlműveletek

Ennyi bántást követően lássunk egy olyan megoldást, ami mintaként kellene hogy funkcionáljon minden programozó számára! A Files mellett az eredeti File osztály is definiál alap fájl műveleteket, ráadásul - véleményem szerint - jóval egyszerűbben és természetesebben. Lássunk erre is egy példát!

import java.io.*;
 
class FileInputExample {
    public static void main(String[] args) {
        File file = new File("mydata.txt");
        System.out.println(file.exists());
        file.delete();
        System.out.println(file.exists());
    }
}

Kell magyarázni, hogy mit hajt végre ez a kód? Szerintem nem! Néhány példa a File által nyújtott műveletekre, magyarázat nélkül, ugyanis mindegyik pont azt hajtja végre, amire a neve alapján számítunk: canRead(), canWrite(), createNewFile(), delete(), exists(), getName(), getAbsolutePath, length(), list() (ez a könyvtárat listázza ki, eredménye String[]), mkdir().

A Java fájlkezelési lehetőségeknek csak egy részét érintettük, de ez elég ahhoz, hogy a legfontosabb feladatokat végre tudjuk hajtani.

Dátumkezelés

A dátumkezelés a Java-ban - a fájlműveletekhez hasonlóan - lehetne átgondoltabb. Kezdetben a java.util.Date osztály feladata volt ennek a kezelése, amit ha paraméter nélkül példányosítunk, akkor az aktuális rendszeróra időpontját kapja értékül. A kiíráshoz viszont szükség van egy másik osztályra: a java.text.SimpleDateFormat-ra. Lássuk, hogyan tudjuk kiírni a pillanatnyi időpontot!

Date now = new Date();
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz");
System.out.println(dateFormat.format(now));

Látható tehát, hogy ennek is nehézkes a használata, ráadásul van benne egy nagyon csúnya tervezési hiba is: úgy a Date mind a SimpleDateFormat nem szálbiztos. Természetszerűleg adná magát az, hogy egy programon belül létrehozunk egy globális SimpleDateFormat példányt, és azt használjuk mindenhol, csakhogy ez sajnos nehezen felderíthető hibákhoz vezet. Ezen kívül az időzónákat sem igazán kezeli.

Adott időpont megadásához a Date()-nek paraméterül a Unix rendszeridőt kell átadnunk, azaz az 1970. január 1-e óta eltelt másodpercek számát. Ez az írás pillanatában 1567178900. Ezen kívül lehetőég van az év, hónap, nap stb. beállítására és lekérdezésére, de már régóta nem javasolt a használatuk.

Az időpont megadásának nehézkessége miatt elég gyorsan megjelentek egyéb módszerek. Az egyik lehetőség a már említett SimpleDateFormat osztály parse() metódusa, pl:

Date birthDay = dateFormat.parse("1977-07-07 08:15:00 CET");

Ez java.text.ParseException-t dobhat, amit le kell kezelni.

A másik módszer (és leginkább javasolt) a java.util.Calendar használata. Itt is egyesével be tudjuk állítani a ádtum elemeket, sőt, olyan dátum műveleteket is biztoít, mint adott számú elem (pl. nap, hónap) hozzáadása vagy kivonása. Lássunk erre is példát!

Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, 2015);
calendar.add(Calendar.MONTH, 20);
Date fromCalendar = calendar.getTime();
System.out.println(dateFormat.format(fromCalendar));

A megadás egyszerűsítése lehetett a célja a GregorianCalendar-nak:

Calendar gregorian = new GregorianCalendar(2019, Calendar.AUGUST, 30, 17, 41, 32);

A hónapok sorszámozása 0-ról indul, tehát pl. az Augusztus a 7-es sorszámot viseli.

Ezt a kuszaságot kívánta orvosolni a Java 8-ban megjelent újabb dátumkezelés. A vonatkozó osztályok a java.time csomagban vannak definiálva.

  • LocalDate: időzóna nélküli dátumot tudunk segítségével létrehozni, pl. LocalDate.now() (ma), LocalDate.of(2019, 8, 30) (év, hónap, nap, teljesen értelemszerűen, nem úgy mint az élelmiszereken) ill. LocalDate.parse("2019-08-30") (nem kell törődnünk a kivételkezeléssel).
  • LocalTime: hasonlóan helyi idő, pl. LocalTime.now(), LocalTime.of(16, 30), LocalTime.parse("16:30:00").
  • LocalDateTime: dátum és idő egyben, pl. LocalDateTime.now().
  • ZonedDateTime: dátum és idő zónainformációval, pl. ZonedDateTime.of(2019, 8, 30, 17, 41, 32, 0, ZoneId.of("UTC+2")).
  • Instant: adott nanoszekundum pontosságú időpillanat, pl. Instant.now().getNano().
  • DateTimeFormatter: az alapértelmezett kiírás teljesen korrekt, nem kell hozzá formázó, de ha nem tetszik, akkor választhatunk további ISO szabványok közül. Pl. DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()).
  • Period: nap pontosságú periódus, pl. LocalDate.now().plus(Period.ofWeeks(4)).
  • Duration: nanoszekundom pontosságú periódus, pl. LocalTime.now().plus(Duration.ofSeconds(100)).

Mindezekhez járulnak a szokásos műveletek, pl. plusDays(). Lássunk egy teljes példát!

import java.time.*;
import java.time.format.DateTimeFormatter;
 
public class Java8DateExample {
 
    public static void main(String[] args) {
        System.out.println(LocalDate.parse("2019-08-30"));
        System.out.println(LocalTime.parse("16:30:00"));
        System.out.println(LocalDate.now());
        System.out.println(LocalTime.now());
        System.out.println(LocalDateTime.now());
        System.out.println(Instant.now().getNano());
        System.out.println(LocalDate.of(1977, 7, 7));
        System.out.println(ZonedDateTime.of(2019, 8, 30, 17, 41, 32, 0, ZoneId.of("UTC+2")));
        System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
        System.out.println(LocalDate.now().plus(Period.ofWeeks(4)));
        System.out.println(LocalTime.now().plus(Duration.ofSeconds(100)));
 
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime dayAfterTomorrow = now.plusDays(2);
        System.out.println(now + ", " + dayAfterTomorrow);
    }
}

Többszálúság

Áttekintés

Ha lehetséges lenne statisztikát készíteni arról, hogy a valaha előfordult programozási hiba mire vezethető vissza, valószínűleg toronymagasan a többszálúság nyerne. Arról van itt szó, hogy egyszerre több folyamat fut párhuzamosan. Például képzeljük el azt, hogy egy webes program várja a felhasználói kéréseket, és a kérések feldolgozása időigényes. Ha úgy valósítanánk meg a programot, hogy amint beérkezik a kérés, rögtön feldolgozzuk adott szálon belül, akkor a feldolgozási idő alatt a többi kérést még fogadni sem tudjuk. Ehelyett tipikus megoldás az, hogy az egyik szál feladata az, hogy fogadja a beérkezett kéréseket és azonnal továbbítsa egy másik szálnak, és utána egyből fogadhatja a következőt, a szálak a háttérben pedig feldolgozzák a kérést és ha kész vannak, visszatérnek az eredménnyel.

Kezdetben egyébként a többszálúság virtuális volt: a valóságban az operációs rendszer szimulálta ezt oly módon, hogy rövid ideig az egyik szál futott, utána a másik, ténylegesen fizikailag tehát egyszerre egy szál futott, ma viszont a többmagos processzorok világában lehetne ténylegesen párhuzamosan futó szálak is. Programozási szempontból egyébként ennek nincs túl nagy jelentősége.

A többszálúság rendkívül sok olyan problémát vet fel, amire elsőre nem is gondolunk. Klasszikus példa az, hogy két szál ugyanazt az erőforrást szeretné használni. Ha mindegyik csak olvasni szeretné, az még jól megoldható, de ha többen szeretnék írni is és olvasni is, akkor az számos problémát felvet. Vegyünk például egy, a valóságtól nem is túl elrugaszkodott esetet: az egyik szál egy tranzakció belül módosítja az adatot, majd kiderül, hogy a tranzakció nem sikerül, és visszacsinálja. A másik szál viszont a két lépés között olvass ki az ideiglenesen módosított adatot.

Erre a problémára már rá lehet vágni a választ: ha valaki írja az adatot, addig a többiek ne férjenek hozzá. Persze azon túl, hogy ennek következtében belassul a program, mert sok lehet a holtidő (amikor sok szál vár egyre), újabb nem várt problémák merülnek fel: pl. az, hogy A erőforrás vár B-re, és B vár A-ra. Ezenkívül ott van az időzítés, annak minden nyűgével.

Ebben a fejezetben megnézzük, hogy hogyan lehet Java-ban szálakat létrehozni, futtatni, és alapvető műveleteket végrehajtani. A téma teljes mélységű elemzése meghaladja az erre a fejezetre szánt kereteket.

A Thread osztály

A Java nyelvben a Thread osztály segítségével tudunk szálakat létrehozni. Egyik módszer az, hogy ebből az osztályból származtatjuk az osztályunkat, és megvalósítjuk a run() metódust. Indítani példányosítás után a start() függvény meghívásával lehet. A szálat a Thread.sleep() eljárással tudjuk adott ideig megállítani, de sajnos itt kezelnünk kell a rendkívül ritkán bekövetkező InterruptedException kivételt. Lássunk egy példát!

class MyThread extends Thread {
    private int n;
 
    public MyThread(int n) {
        this.n = n;
    }
 
    @Override
    public void run() {
        System.out.println("Thread " + n + " started.");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Thread " + n +  " stopped.");
    }
}
 
public class ThreadExample {
    public static void main(String[] args) {
        Thread t1 = new MyThread(1);
        Thread t2 = new MyThread(2);
        Thread t3 = new MyThread(3);
        t1.start();
        t2.start();
        t3.start();
    }
}

A lefutás sorrendje nem garantált, pl. egy konkrét futás a következőképpen néz ki:

Thread 3 started.
Thread 1 started.
Thread 2 started.
Thread 2 stopped.
Thread 3 stopped.
Thread 1 stopped.

Kicsit formálisabban, egy szálnak a Java-ban ötféle állapota lehet:

  • New (új): ebbe kerül, amikor létrehozzuk a Thread példányt. Innen a start() hatására Runnable állapotba kerül, ill. Dead állapotba is kerülhet.
  • Runnable (futható): ez a start() hívás és a tényleges indulás közti rövid állapot. A run() függvényt a Java virtuális gép hívja meg a háttérben, aminek hatására Running állapotba kerül a szál.
  • Running (futó): futás során ebbe kerül. Innen Wait állapotba kerülhet a sleep() (ezt már láttuk) vagy a wait() (ezt majd látni fogjuk) hatására, ill. ha befejeződtt, akkor a Dead állapotba.
  • Waiting (várakozó): amikor ideiglenesen nem fut a szál, pl. mert vár valamire. Normál esetben visszakerül Running állapotba, de elvben egyből Dead állapotba is kerülhet.
  • Dead (halott): miután befejeződött a szál; ezt újraindítani nem lehet.

Egy szálnak van prioritása, ami egy egész szám 1 (legkisebb) és 10 (legnagyobb) között. Alapértelmezésben ez 5, de a setPriority() eljárással ezt át tudjuk állítani.

A Thread osztálynak számos egyéb metódusa van, melyekkel idővel érdemes megismerkedni.

A Runnable interfész

Mivel a Java csak egy ősosztályból való származtatást engedélyez, a fenti módszerrel nem tudnánk egyszerre örökölni is saját osztályból és futtathatóvá tenni. E problémát a következőképpen tudjuk megkerülni: az osztály nem a Thread osztályból származik, hanem a Runnable interfészt valósítja meg. Ugyanúgy a run() metódust kell megírni, az indításhoz viszont a saját osztályunk példányosítása mellett mellett egy Thread példányt is létre kell hoznunk, melynek átadjuk a saját példányunkat paraméterként. A következő példa a fentivel ekvivalens:

class MyRunnable implements Runnable {
    private int n;
 
    public MyRunnable(int n) {
        this.n = n;
    }
 
    public void run() {
        System.out.println("Thread " + n + " started.");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Thread " + n +  " stopped.");
    }
}
 
public class ThreadExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyRunnable(1));
        Thread t2 = new Thread(new MyRunnable(2));
        Thread t3 = new Thread(new MyRunnable(3));
 
        t1.start();
        t2.start();
        t3.start();
    }
}

A syncronized kulcsszó

Ezt követően a szakasz végéig szinkronizálásról lesz szó: hogyan kezeljük azokat a helyzeteket, amikor a szálaknak egymásra kell várniuk? Vegyünk a való életből is egy példát, amivel talán egyszerűbben meg tudjuk érteni az absztrakt problémát! Tegyük fel, hogy van egy orvos mint erőforrás, és betegek mint szálak. Mindenki előbb-utóbb be szeretne menni az orvoshoz, de az orvos egyszerre csak egy beteget tud fogadni.

Vegyük a következő, immár absztrakt problémát! Van egy MyCounter osztályunk, ami - ahogy azt a neve is sugallja - számol, mégpedig a következőképpen: a létrehozásakor 0-ra állítjuk a számlálót, majd egy increment() függvényhívással azt növeljük. A növelés, szándékosan kissé sután, a következőképpen történik: egy ideiglenes változóba tesszük az aktuális számláló értéke + 1-et, majd a számláló felveszi az ideiglenes változó értékét. Egyébként az n++ művelet a háttérben pont így működik, azaz ez nem atomi művelet. Viszont annak érdekében, hogy a fordítónak se legyen lehetősége ezt "kioptimalizálni", a két művelet közé tegyünk egy rövid szünetet.

Most ha több szál ezt az eljárást párhuzamosan hívhatja, akkor a következő problémába futunk:

  • Kezdetben a számláló értéke 0.
  • Az egyik szál meghívja a növelést, melynek hatására az ideiglenes változó értéke 1 lesz, és leáll a futás egy időre.
  • Közben elindul egy másik szál, a számláló még mindig 0, az abban szereplő, más memóriahelyen levő ideiglenes változó felveszi az 1 értéket.
  • Az első szál befejeződik, belehelyezi az ideiglenes változó értékét, azaz az 1-et a számláló értékébe.
  • A második szál is befejeződik, és az is behelyezi az ő ideiglenes változó értékét, azaz szintén az 1-et, a számláló értékébe.

Végeredményben tehát a számláló aktuális értéke 1 lesz, noha 2-nek kellene lennie. A problémát az okozta, hogy a növelés eljárást párhuzamosan hívta két szál, melyet nem lenne szabad megengedni. Ha azt szeretnénk, hogy jól működjön, ezt meg kellene tiltani, magyarán a második szálnak meg kellene várnia, míg az első szál által hívott eljárás befejeződik. A Java-ban ezt a synchronized kulcsszóval tudjuk megtenni, a következőképpen:

class MyCounter {
    private int n = 0;
 
    public synchronized void increment() {
        int temp = n + 1;
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        n = temp;
    }
 
    public int get() {
        return n;
    }
}
 
class MyThread extends Thread {
    private MyCounter counter;
 
    public MyThread(MyCounter counter) {
        this.counter = counter;
    }
 
    @Override
    public void run() {
        counter.increment();
    }
 
}
 
public class SynchronizedExample {
 
    public static void main(String[] args) {
        MyCounter counter = new MyCounter();
 
        Thread t1 = new MyThread(counter);
        Thread t2 = new MyThread(counter);
        Thread t3 = new MyThread(counter);
 
        t1.start();
        t2.start();
        t3.start();
 
        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
 
        System.out.println("Final result: " + counter.get());
    }
 
}

Az orvosos példára visszatérve, a sychronized biztosítja azt, hogy egyszerre legfeljebb egy beteg lehet bent az orvosnál. Ha valaki kijön, akkor utána valaki bemegy, de a sorrend nem garantált.

Itt láthatunk egy példát a join() eljárásra is, ami megvárja, hogy a szál, amin meghívtuk, befejezze a futását, az aktuális szál csak akkor folytatódik. A példában megvárjuk, hogy a 3 szál befejeződjön, és utána írjuk ki a számláló értékét. Az orvosos példánál ez lehet mondjuk a portás, aki megvárja, míg az utolsó beteg is távozik.

Futtassuk le a fenti programot kétféleképpen! Az egyik legyen az, ahogy most kinéz, a másik pedig a synchronized nélkül! Az első esetben az eredmény mindig 3 lesz, a második esetben pedig majdnem biztosan 1. (Hogy miért csak majdnem biztosan, annak a következő az oka: semmi garancia sincs a szálak indítási sorrendjére, azok futására; még a prioritás is csak ajánlás. ELméletben előfordulhat az, hogy ténylegesen olyan lassan megy át Runnable státuszból Running-ba, hogy közben a korábban indított szál már be is fejeződött. Ez esetben 2, vagy akár 3 is lehet a végeredmény.)

A synchronized(this) és társai

Az orvosos példát folytatva tegyük fel, hogy a művelet több részből áll: vetkőzés, vizsgálat, öltözés. Tegyük fel továbbá, hogy van külön női és férfi öltöző, valamint a vizsgálat nettó hossza jelentősen rövidebb a bruttó hossznál (tehát ha beleértjük a vetkőzés és az öltözést is). Adja magát a megoldás: egyszerre több beteget behívnak vetkőzésre, kizárólagosságra csak a vizsgálat ideje alatt van szükség, és utána többen is öltözhetnek egyszerre. Ez a rész pont erről szól!

Írjuk át egy picit az increment() eljárást a következőképpen:

    public synchronized void increment() {
        try {
            System.out.println("increment()");
            Thread.sleep(1000);
            int temp = n + 1;
            Thread.sleep(10);
            n = temp;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

Ezzel azt szimuláljuk, hogy van egy viszonylag hosszú művelet, ami megelőzi a kritikus részt, de ez a szakasz mehetne párhuzamosan. A fenti megoldásban viszont lassú, lesz a lefutás, 3 másodperc, mert mindegyik esetben meg kell várni az előző szál 1 másodperces lefutását. Igazából elég lenne csak pár sort szinkronizálttá tenni. Most eltekintve attól, hogy ebben a konkrét esetben át lehetne szervezni a kódot, a megoldás a következő:

    public void increment() {
        try {
            System.out.println("increment()");
            Thread.sleep(1000);
            synchronized (this) {
                int temp = n + 1;
                Thread.sleep(10);
                n = temp;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

Tehát a teljes eljárás nem lesz szinkronizált, csak 3 utasítás. Ennek viszont egy picit más a szintaxisa, megjelent paraméterként a this. Ez egy referencia, ami az adott példányra hivatkozik. Valójában itt tetszőleges objektumot átadhatunk paraméterül, és azt veszi figyelembe a szinkronizálás során. Ha kettő vagy több eljáráson belül hivatkozunk ugyanarra az objektumra, tehát pl. lenne egy decrement() eljárás, ami hasonló módon csökkenti a számláló értékét, és az is synchronized(this) lenne, akkor amíg az increment() fut, a decrement() nem indulhatna el, és fordítva. Akár több osztályból is hivatkozhatunk ugyanarra az objektumra, és ez esetben az egyik "megakasztja" a másikat. Az orvosos példában elég nyakatekerten ezt úgy tudjuk elképzelni, mintha ugyanabban a szobában egyszerre két orvos is vizsgálna, és egyszerre csak egy beteg tartózkodhatna bent, függetlenül attől, hogy melyik orvoshoz jött. (Tehát nem véletlenül nem egy nagy hodályban rendelnek az orvosok, hanem külön szobákban…)

Ugyanakkor, ha két synchronized különböző objektumot kap paraméterül, akkor az egyik lefutása nem befolyásolja a másikat. Tehát ha van két orvos és két rendelő, akkor az, hogy valaki bent van az egyik rendelőben, nem akadályozza meg azt, hogy más valaki belépje egy másikba.

A következő példa a szintaxist illusztrálja:

public class MyCounter {
    private int m = 0;
    private int n = 0;
    private Object objectM = new Object();
    private Object objectN = new Object();
 
    public void incrementM() {
        synchronized (objectM) {m++;}
    }
 
    public void decrementM() {
        synchronized (objectM) {m--;}
    }
 
    public int getM() {
        return m;
    }
 
    public void incrementN() {
        synchronized (objectN) {n++;}
    }
 
    public void decrementN() {
        synchronized (objectN) {n--;}
    }
 
    public int getN() {
        return n;
    }
}

Az m növelése ill. csökkentése egyszerre egy példányban futhat (tehát ha pl. fut a növelés, akkor nem futhat a csökkenés), és ugyanez igaz az n-re is, de pl. az n növelése és az m csökkentése futhat párhuzamosan.

A wait-notify mechanizmus

Az orvosos példában tegyük fel, hogy a következőképpen zajlik a dolog. Ha megérkezik egy páciens, akkor vár. Amikor kijön az orvostól a beteg, akkor azt kell mondania, hogy "jöhet a következő", és a legfürgébb beteg besurran (nem feltétlenül az, aki legrégebb óta várakozik). Erről szól a wait-notify mechanizmus! Egy másik megoldás: a betegek sorban állnak, a nővér időnként kinéz, és ekkor mindenki beadja a TAJ kártáyáját. Ez utóbbi a wait-notifyAll.

Ha az egyik szálnak meg kell várnia azt, hogy egy másik szál egy bizonyos pontig eljusson, akkor tudjuk használni a wait-notify mechanizmust. Tehát itt nem arról van szó, hogy az egyik szál megvárja, míg a másik befejezi a futást, hanem utána mindkettő fut tovább, de valami miatt (pl. egy részeredmény kiszámolásáig) az egyiknek meg kell várnia a másikat.

Lássunk erre egy példát! A példában két szál fut: az egyik a várakozó (Waiter), a másik a küldő Notifier. A küldő egy másodperces várakozást követően küldi az üzenetet:

package basics.thread;
 
class Message {
    private String message;
 
    public String getMessage() {
        return message;
    }
 
    public void setMessage(String message) {
        this.message = message;
    }
}
 
class Waiter extends Thread {
    private Message message;
 
    public Waiter(Message message) {
        this.message = message;
    }
 
    @Override
    public void run() {
        System.out.println("[Waiter] thread started.");
        try {
            synchronized(message) {
                System.out.println("[Waiter] waiting for message.");
                message.wait();
                System.out.println("[Waiter] message received: " + message.getMessage());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("[Waiter] thread ended.");
    }
}
 
class Notifier extends Thread {
    private Message message;
 
    public Notifier(Message message) {
        this.message = message;
    }
 
    @Override
    public void run() {
        System.out.println("[Notifier] thread started.");
        synchronized(message) {
            System.out.println("[Notifier]  preparing for notify.");
            message.setMessage("my message");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            message.notifyAll();
            System.out.println("[Notifier] notify called.");
        }
        System.out.println("[Notifier] thread ended.");
    }
}
 
public class WaitNotifyExample {
    public static void main(String[] args) throws InterruptedException {
        Message message = new Message();
        Waiter waiter = new Waiter(message);
        Notifier notifier = new Notifier(message);
        waiter.start();
        notifier.start();
        waiter.join();
        notifier.join();
    }
}

Figyeljük meg a következőket:

  • Úgy a várakozónak mind a küldőnek ugyanarra az objektumra kell szinkronizálnia (ld. synchronized(message)).
  • A küldő kódjában van egy ilyen sor: message.notifyAll(), valójában ez az értesítés küldése. Ez kétféle lehet: vagy notify(), amikor pontosan egy várakozó lesz értesítve (ha több Waiter példány lenne, akkor közülük az egyik befejezné a futást, a többi várakozó állapotban maradna), ill. notifyAll(), amikor mindegyik várakozó folytatja a futást.

Lock

Az eddig említett megoldások viszonylag egyszerű szinkronizációs műveletek végrehajtására alkalmasak: egyik vár a másikra. Olyan finomhangolásokra viszont nem alkalmas, mint pl.:

  • A notify során garantáltan a legrégebb óta várakozó szál kapja meg a vezérlést. (Egy orvosnál ez elvárt.)
  • Nézzük meg, hogy a megakadna-e a futás, és ha nem, akkor akkor foglaljuk le az erőforrást, egyébként csináljunk valami mást. Tehát van a szálnak egy kritikus szakasza, de mindegy, mikor hajtja végre, feleslegesen nem szeretne megállni. (Pl. egy csomó mindent el kell intézni, az orvoshoz is el kell menni, de ebben a pillanatban annyira nem fontos, hogy csak akkor megyünk oda, ha nincs ott más. Tehát megnézzük, hogy van-e ott valaki; ha nincs, bemegyünk, ha van, akkor elintézünk mást, és megyünk később. Ill. nézzük me, hogy hányan állnak sorban. Vagy azt nézzük meg, hogy hányan állnak hosszú vizsgálatért sorban, és hányan jöttek csak receptért.)
  • Álljunk be a sorba egy erőforrásért, de ha nem kapjuk meg adott időn belül, akkor lépjünk tovább. (Pl. max. 10 percig állunk sorban az orvosnál, és ha nem szólítottak, akkor hazamegyünk.)
  • A szinkronizálás legyen eltérő adott erőforrás olvasása és írása során.
  • Kívülről meg lehessen szakítani egy szál várakozását abban az esetben is, ha nem kapja meg az erőforrást. (Az orvos kiszól a várakozó betegeknek, hogy menjenek haza, ma már nem kerülnek sorra.)

A példákat lehetne sorolni. Erre alkották meg a Java nyelvben az 1.5-ös verziótól kezdve a Lock osztályhierarchiát (a Lock önmaga egy interfész), melyben számos megvalósítás készült. A két legfontosabb eljárása a lock() és az unlock(), mellyel az erőforrást legyen lefoglalni ill. elengedni, de számos más eljárást is definiáltak.

Először lássunk egy példát, ami a fentiekkel együtt lehet futtathatóvá tenni, majd megnézzük a különböző megvalósításokban rejlő lehetőségeket!

import java.util.concurrent.locks.*;
 
class MyCounter {
    private int n = 0;
    private Lock lock = new ReentrantLock();
 
    public void increment() {
        lock.lock();
        try {
            int temp = n + 1;
            Thread.sleep(10);
            n = temp;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
 
    public int get() {
        return n;
    }
}

A példából már látható, hogy ez sokkal rugalmasabb mint a synchronized, pl. eleve nem kell, hogy ugyanazon a logikai szinten legyen a lock és az unlock. De lássuk, mi mindent tudnak még!

  • A Lock interfész:
    • lock() és unlock(): ezeket már láttuk.
    • tryLock(): ha szabad a lock, akkor megkapja, és igaz értékkel tér vissza. Ha nem akkor a visszatérési érték hamis lesz, de a futás azonnal folytatódik. Ennek párja az, amikor paraméterként meg lehet neki adni, hogy mennyi ideig próbálkozzon. (Ez az a példa, amikor csak akkor megyek be az orvoshoz, ha azonnal be tudok menni, vagy max. 10 percig várok. Ne feledjük: alapértelmezésben a lock mechanizmus olyan, hogy addig vár, amíg az erőforrás meg nem kapja, ami akár észszerűtlenül hosszú is lehet.)
    • lockInterruptibly(): a lock kívülről megszakítható. (A beteg elhatározza, hogy addig vár, amíg nem szólítják, de szól az orvos vagy egy családtag, hogy ne várjon tovább.)
    • newCondition(): tetszőleges számú feltételt (Condition osztály) lehet ennek segítségével a lockhoz hozzáadni. Ez hasonlóképpen működik, mint a már megismert wait-notify: az await(), a notify() ill. notifyAll() a már megismert technológia szerint működik. (Tegyük fel, hogy külön sor van a receptekért és a vizsgálatokért.)
  • ReentrantLock: alap Lock megvalósítás, amely a fenti eljárásokon túl többek között a következőket is tartalmazza:
    • getQueueLength(): az adott sorra várakozó erőforrások hosszát adja vissza. (Tehát pl. hányan állnak sorban az orvosnál.)
    • getWaitQueueLength(condition): adott Condition-re vonatkozó várakozók száma. (Pl. hányan várnak vérvételre.)
    • getHoldCount(): azt adja vissza, hogy az adott szál hányszor foglalta le a lock-ot (ugyanis többször is lefoglalhatja). (Ezt úgy lehetne elképzelni, hogy a beteg bent van az orvosnál, majd indítanak egy újabb folyamatot, pl. EKG-t; azaz mintha kétszer lenne bent: először egy általános vizsgálat miatt, másodszor pedig az EKG miatt. Tehát meg kell várni az EKG-t, majd a teljes vizsgálat végét, hogy a következő beeg is beléphessen.)
    • hasQueueTreads() ill. hasQueueTread(thread): segítségével lekérdezhetjük, hogy van-e várakozó szál, ill. egy adott szál éppen várakozik-e. (Van-e még valaki, aki sorban áll, mert ettől függ, hogy végrehajtanak-e egy időigényes vizsgálatot. A másikra példa: Mari néni itt van-e még.)
    • isFair(): be lehet állítani azt, hogy a legrégebb óta várakozó kapja meg legelőször a felszabaduló erőforrást. Ez az eljárás azzal tér vissza, hogy ez be van-e állítva. (Az orvosi példában ez nyilván azt jelenti, hogy a legrégebb óta sorban álló beteg léphet be következőnek.)
  • ReentrantReadWriteLock: a valóságban leggyakrabban író és olvasó műveletek vannak. Az eddigi megoldásokkal íráskor és olvasáskor is meg kellett szerezni a lockot. De valójában ezt optimálisabban is meg lehet valósítani: párhuzamosan több szál is olvashat biztonságosan, azt viszont biztosítani kell, hogy egyszerre több ne írhasson, ill. ha valamelyik ír, akkor egyik se olvashasson. Kicsit precízebben: két olvasó futhat egyszerre; egy olvasó megakasztja az írót; az író megakasztja az olvasót; az író megakasztja a többi írót. A való életben képzeljünk el egy piacot, ahol néha felülírják az árakat. Akárhány vásárló nézheti az árakat egyszerre. Írás közben jó, ha senki sem látja, mert a 200 forintos paradicsom árának kiírásánál lesz olyan időpillanat, amikor 20-at lát a vevő. Vásárlás közben (pl. az áru kiválasztása és a fizetés között) nem illik árat emelni. Azt is biztosítani kell, hogy egyszerre egy eladó írja ki az árat a táblára. Ezt deklarálja a ReadWriteLock interfész ill. valósítja meg a ReentrantReadWriteLock osztály.
    • readLock(): visszaad egy read lockot, melyek hagyományos módon tudunk használni: lock()), unlock.
    • writeLock: hasonló a read lockhoz.
    • getReadLockCont() és getWriteLockCount: azoknak a szálaknak a számát adja vissza, amelyek a read ill. write lockra várnak. (Van még több, kifejezetten a read ill. write lockkal kapcsolatos lekérdezéssel kapcsolatos eljárás, melyhez érdemes részletesebben megnézni a dokumentációt.)
    • isFair(): a konstruktorban át lehet adni azt, hogy igazságos-e a lock. Alapértelmezésben nem az, de ha beállítjuk, akkor garantált lesz az, hogy a legrégebb óta sorban álló kapja meg az erőforrást legelőször.
  • StampedLock: a tényleges ütközések száma, tehát amikor egyszerre akarnak többen írni és olvasni, igazából elég ritka. Ha mondjuk ezer esetből csak egyszer fordul elő, akkor valójában 999 esetben feleslegesen hajtottuk végre a lock-unlock műveletet.Ennek kezelésére találták ki az optimista lockolási mechanizmust: azt feltételezzük, hogy nem okoz problémát a végrehajtandó művelet, és ha kiderül, hogy mégis, akkor újra végrehajtjuk, immár rendes lockolással. (A valóságban ezt úgy lehetne elképzelni, hogy van egy hivatal, ahol rendkívül ritka az ügyfél. Nehéz ezt persze elképzelni, de most tegyük fel! Ebben a hivatalban azonnal kiszolgálják az ügyfelet, ha nincs más. EGyébként sorszámot kell kérni, a sorszámkiadó automata viszont egy másik épületben van. A fenti megvalósítás úgy működne, hogy elmegyünk sorszámot kérni, majd utána ügyet intézni. Ha viszont 1000 esetből 999-ben feleslegesen kérünk sorszámot, jobban megéri először megpróbálni elintzni az ügyet, és ha vannak előttünk, akkor átmenni sorszámot kérni. Ebben az egyetlen esetben ez a módszer persze hosszabb lesz, mintha egyből sorszámot kértük volna, pl. akár közben a sorszámkiadó automatánál meg is előzhetnek minket, mégis, hosszú távon jobban járunk azzal, ha először bekockáztatjuk azt, hogy sorszám nélkül is kiszolgálnak.) Ezt valósítja meg a Java-ban a StampedLock, ami az (egyébként rendkívül sok alapvető újítást tartalmazó) 1.8-as verzióban jelent meg. Ebben megjelenik a bélyegző (stamp) fogalma: az egyes műveletek egy bélyeget adnak vissza. Lássuk a tipikus műveleteket:
    • long stamp = lock.tryOptimisticRead();: olvasás optimista hozzáállással.
    • if (!lock.validate(stamp)) {…}: annak ellenőrzése, hogy a bélyegző még érvényes-e. Ezt tipikusan az írás után hajtjuk végre, és ha érvényes (a visszatérési érték igaz), az azt jelenti, hogy az olvasás során nem történt pl. változás az adatbázisban, tehát a kiolvasott érték megfelelő. Ha nem, akkor a visszatérési érték hamis, és ez esetben a hagyományos módon kell végrehajtanunk a kiolvasást.
    • stamp = lock.readLock();: ez a nem optimista lockolás: megvárjuk, amíg az írók befejezték az írást, nem feltételezzük, hogy nincs ilyen, és csak utána olvasunk.
    • lock.unlock(stamp);: a lock elengedésének a módja. Ezt tipikusan egy finally blokkban hajtjuk végre, hogy mindenképpen lefusson, és ne fordulhasson elő az, hogy a végtelenségig "beragad".

Szemafor

Sokszor előfordul az a probléma, hogy a terhelés során elfogy valamilyen erőforrás, mert túl sokan használják egyszerre a rendszert. Erre túl drasztikus az a megoldás, hogy egyszerre csak egy használhassa, viszont korlátokat mégis érdemes bevezetni, pl. azt, hogy egyszerre maximálisan hányan használhatják. Ezt valósítja meg a szemafor, a Java-ban a Semaphore osztály. Lássunk egy példát!

import java.util.concurrent.*;
 
class Resource {
    private Semaphore semaphore = new Semaphore(3);
 
    public void process() {
        try {
            System.out.println(Thread.currentThread().getName() + " Resource.process() called");
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + " Resource.process() semaphore acquired, processing");
            Thread.sleep(1000);
            semaphore.release();
            System.out.println(Thread.currentThread().getName() + " Resource.process() semaphore released");
        } catch (InterruptedException ie) {
            ie.printStackTrace();
        }
    }
}
 
class MyThread extends Thread {
    private Resource resource;
 
    public MyThread(String name, Resource resource) {
        super(name);
        this.resource = resource;
    }
 
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " started");
        resource.process();
        System.out.println(Thread.currentThread().getName() + " ended");
    }
 
}
 
public class SemaphoreExample {
    public static void main(String[] args) throws InterruptedException {
        Resource resource = new Resource();
        MyThread t1 = new MyThread("T1", resource);
        MyThread t2 = new MyThread("T2", resource);
        MyThread t3 = new MyThread("T3", resource);
        MyThread t4 = new MyThread("T4", resource);
        MyThread t5 = new MyThread("T5", resource);
        t1.start();
        Thread.sleep(10);
        t2.start();
        Thread.sleep(10);
        t3.start();
        Thread.sleep(10);
        t4.start();
        Thread.sleep(10);
        t5.start();
    }
}

A futás eredménye:

T1 started
T1 Resource.process() called
T1 Resource.process() semaphore acquired, processing
T2 started
T2 Resource.process() called
T2 Resource.process() semaphore acquired, processing
T3 started
T3 Resource.process() called
T3 Resource.process() semaphore acquired, processing
T4 started
T4 Resource.process() called
T5 started
T5 Resource.process() called
T1 Resource.process() semaphore released
T1 ended
T4 Resource.process() semaphore acquired, processing
T2 Resource.process() semaphore released
T2 ended
T5 Resource.process() semaphore acquired, processing
T3 Resource.process() semaphore released
T3 ended
T4 Resource.process() semaphore released
T4 ended
T5 Resource.process() semaphore released
T5 ended

Tehát a T4 csak a T1, a T5 pedig a T2 lefutása után jut szóhoz.

A szál pool mechanizmus

A pool-t a programozásban nagyjából úgy kell elképzelni, mint egy többablakos postahivatalt: egyszerre annyi ügyfelet tudnak kiszolgálni, ahány ablak van, és ha egy ablak felszabadul, akkor szólítják a következőt. A szemafor felfogható egyfajta kezdeti megvalósításnak: a fenti példát elképzelhetjük úgy is, hogy egyszerre öten érnek a postára, de csak 3 ablak van. A pool általában viszont jobban hasonlít a postai példára, mint csak az, hogy egyszerre kizárjuk azt, hogy egy adott határnál több szál fusson: azt is meg tudjuk mondani, hogy ki melyik erőforrást használja.

Ez a mechanizmus az 5-ös Java-ban jelent meg. Lássunk rögtön egy példát!

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
class CommandThread extends Thread {
    private String command;
 
    public CommandThread(String command) {
        this.command = command;
    }
 
    @Override
    public void run() {
        System.out.println("Thread " + Thread.currentThread().getName() + " started. Command: " + command);
        try {
            Thread.sleep(Math.round(Math.random() * 5000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Thread " + Thread.currentThread().getName() + " ended. Command: " + command);
    }
}
 
public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            CommandThread commandThread = new CommandThread("" + i);
            executor.execute(commandThread);
          }
        executor.shutdown();
        while (!executor.isTerminated()) {}
        System.out.println("Finished all threads");
    }
}

A szál maga egy maximálisan 5 másodperc hosszú véletlen ideig vár. A főprogramban létrehozunk egy 5 szálat tartalmazó pool-t. A háttérben feltehetőleg előkészületeket hajt végre a rendszer; ilyen értelemben hasonlít a pool a postás példára: a postán akkor is van 5 ablak, ha épp zárva van a posta, és nem az első ügyfélnél kell azt kialakítani. Majd indítunk 10 szálat. Ebből az első ötöt elindítja azonnal, hozzárendeli a pool megfelelő elemeihez, majd ha valamelyik végzett, akkor indítja a következőt. A végén megvárja hogy mind befejezze a futást.

A java.util.concurrent csomagban érdemes még szétnézni, mert olyan osztályokat is találunk, amellyel a fentinél még jobban tudjuk vezérelni a szál pool-okat. Pl. a ThreadPoolExecutor segítségével megadhatunk egy maximális értéket, ami fölé már a várakozók száma sem mehet. (A postás hasonlattal ez olyan, hogy 5 ablak van de ha már 20-an bent tartózkodnak, akkor a 21. már sorba sem állhat.) Azt is meg tudjuk adni, hogy ha egy bizonyos ideig nincs kihasználva a pool akkor a lefoglalt erőforrást felszabadítja, és újraindítja, ha szükséges. (A postai példát folytatva: ha tartósan legfeljebb 3 az egyszerre bent tartózkodó ügyfelek száma, akkor két alakot bezárnak, és újra nyitják, ha ismét szükség lesz rá.)

A jövőbeli eredmény

Ha egy művelet hosszú ideig tart, aszinkron módon is meghívhatjuk úgy, hogy új szálat indítunk. Ez esetben nem kapjuk meg rögtön az eredményt, azt rendszeresen le kell kérdezni. Noha magunk is meg tudjuk valósítani például globálisan elérhető (statikus) változók segítségével, sokkal elegánsabb ezt megtenni a Java által nyújtott osztályokat használva. Callable és Future osztályok segítségével. A legegyszerűbb egy példán keresztül megismerni!

import java.util.concurrent.*;
 
class SquareCalculator {
    private ExecutorService executor = Executors.newSingleThreadExecutor();
 
    public Future<Integer> calculate(Integer input) {
        return executor.submit(() -> {
            Thread.sleep(1000);
            return input * input;
        });
    }
}
 
public class FutureExample {
    public static void main(String[] args) throws Exception {
        Future<Integer> future = new SquareCalculator().calculate(5);
        while (!future.isDone()) {
            System.out.println("Calculating...");
            Thread.sleep(300);
        }
        Integer result = future.get();
        System.out.println(result);
    }
}

A példában a négyzetszámoló vár egy másodpercet, mielőtt visszatérne az eredménnyel, a hívó pedig aszinkron módon hívja, és 3 tizedmásodpercenként lekérdezi, hogy meg van-e már az eredmény, az isDone() függvény segítségével. Végül a get() adja vissza az eredményt. Ezen eljárások során különféle kivételek váltódhatnak ki, melyeket a példában nem kezeljük, hogy lehetőleg minél tömörebb marajdon a kód.

Összefoglalás

Az alfejezet hosszából is láthatjuk, hogy valójában mennyire összetett probléma a többszálúság megfelelő kezelése. A Java nyelvi elemekkel (synchronized), az Object által nyújtott alapfüggvényekkel (wait, notify, notifyAll) és standard könyvtárakkal (a java.util.concurrent csomagban található osztályok, interfészek) támogatja a többszálúságot.

Funkcionális programozás

A programozás oldal megfelelő alfejezete áttekintést nyújt a funkcionális programozásról. A gyakorlatban ez azt jelenti, hogy a függvények "első osztályú állampolgárokká" válnak: át lehet őket adni paraméterként, lehetnek visszatérési értékei, célszerű tiszta függvényekkel dolgozni stb.

A Java nem funkcionális programozási nyelv, a 8-as verziójában viszont megjelentek olyan elemek, amelyek funkcionális elemeket tartalmaznak. Ezeket tekintjük át most. E szakasz megírásában sokat segített a következő oldal: http://tutorials.jenkov.com/java-functional-programming/.

Funkcionális interfészek

Funkcionális interfészeknek nevezzük azokat az interfészeket, melyek összesen egy függvényt tartalmaznak. Egészen pontosan: egy olyan függvényt, ami nincs megvalósítva. Emlékeztetőül: pont a Java 8-ban jelent meg az alapértelmezett megvalósítást tartalmazó függvény lehetősége. Ez utóbbinak később jelentősége lesz; most elég csak annyit megjegyeznünk, hogy ilyen is lehetséges.

Vegyünk egy egyszerű példát:

interface MathOperation {
    int perform(int a, int b);
}

A MathOperation egy olyan függvényt deklarál, amely két egészet vár paraméterül, és egy egészet ad vissza. Mivel tehát ez egy olyan interfész, melyben pontosan egy nem megvalósított függvény fejléc szerepel, funkcionális interfésznek nevezzük. Ebben tehát idáig nincs semmi nyelvi újítás, csak megnevezés.

Lambda kifejezések

A lambda kifejezés a legnagyobb nyelvi újítás a Java 8-ban (de megkockáztatom, hogy a Java történetében az első verzió óta). Először viszont folytassuk a fenti példát!

Tekintsük a következő függvényt!

void performOperation(int a, String sign, int b, MathOperation mo) {
    System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
}

Tegyük fel, hogy a fenti interfészt kétféleképpen szeretnénk megvalósítani: az egyik összeadás, a másik szorzás. A Java 8 előtti világban ehhez létre kellett hoznunk egy-egy osztályt, a perform() függvényt megvalósítani, az osztályt példányosítani, majd átadni a fenti függvénynek, valahogy így:

interface MathOperation {
    int perform(int a, int b);
}
 
class AddOperation implements MathOperation {
    @Override
    public int perform(int a, int b) {
        return a + b;
    }
}
 
class MultiplyOperation implements MathOperation {
    @Override
    public int perform(int a, int b) {
        return a * b;
    }
}
 
public class NonFunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, new AddOperation());
        performOperation(2, "*", 3, new MultiplyOperation());
    }
}

Ez utóbbin a Java 8 előtti világban úgy tudunk egyszerűsíteni, hogy - feltéve, hogy nincs szükségünk az AddOperation és MultiplyOperation osztályokra egyébként, hogy anonymous osztályt hozunk létre:

package basics;
 
interface MathOperation {
    int perform(int a, int b);
}
 
public class NonFunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, new MathOperation() {
            @Override
            public int perform(int a, int b) {
                return a + b;
            }
 
        });
        performOperation(2, "*", 3, new MathOperation() {
            @Override
            public int perform(int a, int b) {
                return a * b;
            }
        });
    }
}

Itt jön a nagy ugrás: mivel a MathOperation egy funkcionális interfész, valójában csak egyetlen függvényt kell megvalósítanunk, minek írnánk ki a nevét? Erre alkották meg a -> (lambda) operátort a Java 8-ban. Az összeadás a következőképpen néz ki enne segítségével: (int a, int b) -> {return a + b;}. A fenti kód ennek következtében az alábbira egyszerűsödik:

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (int a, int b) -> {return a + b;});
        performOperation(2, "*", 3, (int a, int b) -> {return a * b;});
    }
}

Néhány szó a szintaxisról:

  • A paramétereket kerek zárójelekben adjuk meg, mint ahogy a többi függvénynél megszokhattuk.
  • A paraméter típusok megadása opcionális, ugyanis ezt a fordító ki tudja következtetni (type inference). Tehát például az összeadás tovább egyszerűsödik: (a, b) -> {return a + b;}
  • Ha összesen egyetlen return utasítás van, akkor a kapcsos zárójel is elhagyható: (a, b) -> a + b;
  • Elképzelhető, hogy nincs paraméter; ez esetben is ki kell tenni a zárójeleket, pl. () -> new MyObject() (ha pl. az interfészben deklarált függvény így néz ki: MyObject create();).
  • Ha egyetlen paraméter van, akkor a zárójel opcionális. Pl. a következő kettő ekvivalens: (x) -> 2 * x és x -> 2 * x. Ez utóbbi szintaxis egyébként a lambda kifejezések talán legnagyobb ereje, amikor igazán tömöré válik a kód.

Lássuk a fenti példát úgy, hogy minden egyszerűsítési lehetőséget kihasználunk:

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (a, b) -> a + b);
        performOperation(2, "*", 3, (a, b) -> a * b);
    }
}

Látható tehát, hogy rendkívül tömörré vált a kód adott része, ami az igazi funkcionális programozási nyelvekre emlékeztet. Ez a valóságban kisebb részt nyelvi elem (mindenekelőtt a -> lambda operátor), nagyobb részt a fordító "felokosítása" (pl. típus következtetés a funkcionális interfész segítségével), a háttérben viszont ugyanúgy létre jön egy osztálypéldány, ami az adott függvény megvalósítását tartalmazza. Függvényt magát tehát továbbra sem adhatunk át paraméterként, csak olyan osztályt, ami egy függvényt tartalmaz.

Metódus referencia

Tegyük fel, hogy van egy Printer interfész, ami tartalmaz egy void print(String text) metódust, valamint egy olyan függvény, ami két paramétert vár, egy String-et és egy Printer-t, és meghívja a Printer-en a print(text)-et. Paraméterként átadhatjuk neki ezt: print(text, (s) -> System.out.println(s));. Lássuk, hogy miről van szó! (Ez nem futtatható példa, az a szakasz végén lesz.)

interface Printer {
    void print(String s);
}
 
static void print(String text, Printer printer) {
    printer.print(text);
}
 
print(text, (s) -> System.out.println(s));

Az utolsó sort a következőképpen is írhatjuk:

print(text, System.out::println);

Vegyük észre, hogy a println() a System.out egy statikus függvénye. A következő referenciákat tudjuk használni:

  • Statikus metódus, mint pl. fent.
  • Az egyik paraméter metódusa; pl. a következő kettő ekvivalens (s1 és s2 mindkettő String típusú): Finder finder = (s1, s2) -> s1.indexOf(s2); és Finder finder = String::indexOf;.
  • Egy példány metódusa, hasonlóan a statikus metódushoz.
  • Konstruktor referencia, pl. String::new.

Lássunk mindegyikre egy működő példát (ami az eredeti felvetést is tartalmazza)!

interface Printer {
    void print(String s);
}
 
interface Factory {
    String create(char[] val);
}
 
interface Finder {
    int find(String s1, String s2);
}
 
interface Converter {
    public String convert(String text);
}
 
class Capitalizer {
    public String capitalize(String text) {
        return text.toUpperCase();
    }
}
 
public class MethodReferenceExample {
    static void print(String text, Printer printer) {
        printer.print(text);
    }
 
    static void find(String text1, String text2, Finder finder) {
        System.out.println(finder.find(text1, text2));
    }
 
    static void convert(String text, Converter converter) {
        System.out.println(converter.convert(text));
    }
 
    public static void main(String[] args) {
        char[] chars = {'H', 'e', 'l', 'l', 'o'};
        Factory factory = String::new;
        String text = factory.create(chars);
        print(text, (s) -> System.out.println(s));
        print(text, System.out::println);
        find(text, "ll", String::indexOf);
        convert(text, (new Capitalizer())::capitalize);
    }
}

Függvények átadása paraméterként és visszatérési értékként

A funkcionális programozásban magasabb rendű függvénynek (higher order function) hívjuk azokat a függvényeket, melyeket paraméterül adunk át vagy egy függvény visszatérési értéke. A fenti példa már bemutatta, hogy függvényt mi módon tudunk átadni paraméterként egy másik függvénynek (ami a valóságban olyan név nélküli osztály egy példányát jelenti, ami egyetlen függvényt tartalmaz).

Lássunk egy példát a visszatérési értékre is! Létrehozunk egy olyan eljárást, ami visszatérési értékként a paraméterül kapott műveleti jeltől függő függvénnyel tér vissza: "+" esetén összeadással, "*" esetén szorzással.

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    static MathOperation createMathOperation(String sign) {
        switch (sign) {
        case "+": return (a, b) -> a + b;
        case "*": return (a, b) -> a * b;
        }
        return (a, b) -> 0;
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, createMathOperation("+"));
        performOperation(2, "*", 3, createMathOperation("*"));
    }
}

A kód ránézésre olyan, mintha tényleg függvényt kapna paraméterül ill. azt adna vissza; ezt nem tudnánk megtenni, ha nem vezették vona be a funkcionális interfész fogalmát.

Beépített funkcionális interfészek

Ahogy láthattuk, mi magunk is hozhatunk létre funkcionális interfészeket. Számos funkcionális interfészt definiál ugyanakkor maga a Java keretrendszer a java.util.function csomagban, feltehetően leginkább abból a megfontolásból, hogy a hasonló megvalósítások egységesek legyenek. Ezek közül nézünk meg most párat:

  • Function: az apply() függvény paramétere és visszatérési értéke is generikus. Ennek speciális esete az UnaryOperator, melynek paramétere és visszatérési értéke ugyanolyan típusú.
  • Predicate: a test() függvény paramétere generikus, visszatérési értéke boolean.
  • BinaryOperator: az apply() két ugyanolyan típusú paramétert vár, mely megegyezik a visszatérési típussal.
  • Supplier: a get() függvénynek paramétere nincs, visszatérési értéke generikus.
  • Consumer: az accept() függvénynek egy generikus paramétere van, a visszatérési típusa pedig void.

Példaként tekintsünk a fentinek a BinaryOperator változatát!

import java.util.function.*;
 
public class FunctionalInterfaceExample {
    static void performOperation(int a, String sign, int b, BinaryOperator<Integer> bo) {
        System.out.println(a + " " + sign + " " + b + " = " + bo.apply(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (a, b) -> a + b);
        performOperation(2, "*", 3, (a, b) -> a * b);
    }
}

A BinaryOperator generikus típusa csak valamilyen osztály lehet, primitív típus nem. Ebben a példában az autoboxing is megjelenik.

Funkcionális kompozíció

A funkcionális interfész definíciójánál szó volt arról, hogy azok tartalmazhatnak tetszőleges számú megvalósított függvényt. Ilyeneket a keretrendszer által nyújtott interfészek is tartalmaznak, melyekből pár fontosabb példát említek:

  • Predicate: két predikátumot az and() és az or() függvényekkel tudunk összefűzni, melynek eredménye szintén predikátum. Pl. ha a multipleOfTwo és a multipleOfThree két predikátum, melyek számot várnak, és akkor térnek vissza igaz értékkel, ha a paraméterül kapott szám a kettőnek ill. a 3-nak többszörösei, akkor a multipleOfTwo.and(multipleOfThree) a 6 többszöröseire fog igazzal visszatérni.
  • Function: két függvény egymás után fűzhető a compose() és az andThen() függvényekkel. Az f.compose(g) estén előbb a g hajtódik végre, utána az f(), az f.andThen(g) pedig fordítva.

Lássunk erre is egy példát, melyben létrehozunk egy olyan, egészet váró és egészet visszaadó függvényt, amely megnöveli a paraméterül kapott értéket eggyel, ill. egy olyat, ami megduplázza, és ezt fűzzük egymás után, ebben a sorrendben.

import java.util.function.*;
 
public class FunctionCompositionExample {
    static void performOperation(int a, Function<Integer, Integer> f) {
        System.out.println(a + " -> " + f.apply(a));
    }
 
    public static void main(String[] args) {
        Function<Integer, Integer> increment = a -> a + 1;
        Function<Integer, Integer> duplicate = a -> a * 2;
        Function<Integer, Integer> incrementThenDuplicate = increment.andThen(duplicate);
        performOperation(2, incrementThenDuplicate);
    }
}

Ebben a példában azt is láthatjuk, hogy a kompozíció eredménye szintén egy olyan függvény, ami egészet vár és egészet ad vissza.

Összegzés

A Java eredendően nem egy funkcionális nyelv, és a Java 8-ban sem lett az. Viszont bevezettek olyan újításokat, amelyek komoly lépéseket jelentenek a funkcionális programozás irányába. Ennek a használt a következő alfejezetben fogjuk látni. Legvégső soron legalul viszont továbbra is egy függvényt tartalmazó osztálypéldányok vannak, tehát függvényt ténylegesen nem tudunk átadni paraméterként, sem visszatérni vele visszatérési értékként. Viszont olyan funkcionális finomságokat, mint pl. a currying a Scalaban, Javaban továbbra sem tudunk megvalósítani. Ugyanakkor tudunk olyan kódot írni, ami első ránézésre funkcionálisként hat.

Egyetlen lépést még hiányolok a nyelvben: amikor használjuk a paraméterül átadott függvényt, akkor tudnunk kell a függvény nevét. Az első példában volt egy MathOperation interfész, azon belül egy perform() függvény, és a tényleges kiszámoláskor így kellett használnunk: mo.perform(a, b). De mivel összesen egy eljárást tartalmaz a funkcionális interfész, valójában a függvény nevét is ki kellene tudnia következtetni, így jó lenne, ha ez is működne: mo(a, b). Ez még egy lépés lenne a funkcionalitás felé.

Folyamok

Áttekintés

A folyam (stream) a Java 8-ban jelent meg, a nyelv funkcionális kiegészítőivel együtt. Nem összetévesztendő a Java IO stream fogalmával, pl. a FileInputStream-mel, bár rokon fogalmak. Ez a stream leginkább a Java Collections API kiterjesztéseként is felfogható: az ott definiált adatszerkezetekben található adatok átalakíthatóak folyammá, melyen egyesével hajtunk végre műveleteket. E megközelítéssel fel tudunk dolgozni akkora adatmennyiséget is, ami egyszerre nem fér be a memóriába. A folyamon végrehajtható műveleteket két fő csoportba osztjuk, melyek megértése nélkülözhetetlen a stream-ek megértéséhez:

  • Nem végműveletek (nonterminal operations): egy-egy ilyen művelettel azt mondjuk meg, hogy amikor a feldolgozás egy adott elemhez ér, akkor mi történjen vele. Tipikus ilyen művelet a map(), ami átalakítja egy másik értékké (pl. a már megismert szintaxissal: map(x -> 2*x) azt jelenti, hogy a szám kétszeresét veszi; persze ez csak akkor működik, ha a folyam elemei számok), vagy a filter(), ami kiszűr bizonyos elemeket (pl. a filter(x -> x % 2 != 1) kiszűri a páratlan számokat). Az egyes műveletekkel a későbbiekben ismerkedünk meg. Most viszont fontos megjegyeznünk, hogy a művelet (azaz az átalakítás vagy a szűrés) egyelőre ténylegesen nem hajtódik végre. Valamint azt is fontos tudatosítani, hogy egyszerre egy elemet látunk; nem hajthatunk végre pl. rendezést, mert nem tudunk olyat mondani, hogy vedd az ilyen és ilyen indexű elemet, hasonlítsd őket össze stb.
  • Végműveletek (terminal operations): ennek hatására hajtódnak végre a nem végműveletek. Végművelet pl. a forEach(), ami végiglépked mindegyik elemen, és végrehajt bizonyos műveletet (pl. a forEach(System.out::println) kiírja; a szintaxis megértéséhez ld. a magyarázatot fent a metódus referencia szakaszban), vagy pl. a count(), ami megszámolja, hogy hány elem található a folyamban. Egy stream-en egy végműveletet lehet csak végrehajtani. Ráadásul ha egy stram-et egyszer elindítottuk, és befejeződött, azt újraindítani nem lehet. Lehet viszont másolatot készíteni, és a másolatot elindítani, vagy tudunk készíteni saját ún. Collector-t, melyet át tudunk adni a collect() végműveletnek, és az tetszőleges műveletet végrehajthat.

Példa

Lássunk egy példát!

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
 
public class StreamExample {
    public static void main(String[] args) {
        List<String> fruits = Arrays.asList("apple", "banana", "orange", "banana", "apple");
        Stream<String> fruitsStream = fruits.stream();
        fruitsStream
            .filter(s -> s.contains("n"))
            .distinct()
            .map(s -> s.toUpperCase())
            .forEach(s -> System.out.println(s));
    }
}

A példában létrehoztunk egy 5 elemű, szavakat tartalmazó listát, hagyományos Java Collections API módszerrel, majd a stream() eljárással folyamot készítettünk belőle. Első műveletünk egy szűrés: csak azok a szavak maradnak meg, melyek tartalmaznak n karaktert. Figyeljük meg, hogy ez úgy eldönthető, hogy csak egy elemet látunk.

Álljunk meg egy pillanatra, és gondolkozzunk el ezen a kérdésen: ebben a pillanatban mi történik? Ha hagyományos gyűjteményről lenne szó, akkor azt mondanánk, hogy már csak 3 elemű a lista: banana, orange és banana, mert csak ezekben van n karakter, a kétszer szereplő apple szóban nincs. Viszont mivel itt folyamokról van szó, fontos megértenünk, hogy ez nem így van: valójában a folyam továbbra is 5 elemet tartalmaz, és annyit mondtunk neki, hogy majd ha valaki elindítja a folyamot, akkor szűrd ki azokat a szavakat, melyekben van n betű. Az eredmény ez a módosított folyam.

A következő művelet a distinct(). Láthatjuk, hogy magán az eredményen hajtjuk végre; megvalósíthatnánk úgy is, hogy változóba mentjük a filter() által visszaadott Stream-et, majd azon végrehajtjuk a következő műveletet, és így tovább, a stream programozásakor viszont általában a láncolt (chained) megoldást szoktuk alkalmazni. Visszatérve a distinct()-re: ez is egy nem végművelet, ami kiszűri a duplikátumokat. Itt jó kérdés, hogy ezt hogyan valósítja meg, mert továbbra is abból kell kiindulnunk, hogy egyszerre egy elemet látunk. Feltehetőleg létre hoz egy globális szótárat, melybe beleírja, hogy mely elemek vannak már benne, majd ehhez a szótárhoz hasonlít akkor, amikor egy adott elemről kell eldönteni, hogy benne maradjon-e. Így feltehetőleg mindig az elsőként megtalált elem marad benne a folyamban. De ismét igaz a fent megfogalmazott alapelv: mivel nem végműveletről van szó, a valóságban ezen a ponton még nem történik semmi.

A következő művelet a map(), ami nagybetűssé konvertálja a szót. Azt gondolom, hogy az s -> s.toUpperCase() tömör és jól érthető kódrészlet; itt létható a korábban bemutatott funkcionális szintaxis nagy előnye.

Végül végrehajtunk egy végműveletet, ami jelen példában a forEach(): ez végigiterál mindegyik elemen, és kiírja az eredményt. És ez fogja kiváltani a nem végműveletek végrehajtását is. Valahogy a következőképpen történhet mindez:

  • apple → nincs benne n, tehát töröld ki
  • banana → van benne n, tehát ne töröld ki → kerüljön be a szótárba, de maradjon meg → változtasd nagybetűssé: BANANA → írd ezt ki
  • orange → van benne n, tehát ne töröld ki → kerüljön be a szótárba, de maradjon meg → változtasd nagybetűssé: ORANGE → írd ezt ki
  • banana → van benne n, tehát ne töröld ki → már benne van a szótárban, tehát töröld ki
  • apple → nincs benne n, tehát töröld ki

Az eredmény tehát az ORANGE és a BANANA lesz, külön sorban. A fenti példában az is látható, hogy a sorrend nagyon nem mindegy. Pl. ha megcserélnénk a distinct() és a filter() függvényeket, akkor megspóroltuk volna a második banana filter() függvényét, de írhattuk volna rosszabbul is: pl. ha előbb alakítjuk nagybetűssé, akkor 3 felesleges, és valójában drága műveletet hajtottunk volna végre.

Most nézzük meg a lehetőségeket!

Nem végműveletek

Számos nem végművelet létezik, melyek közül most bemutatok párat:

  • map(): átalakítás, erről már volt szó.
  • filter(): szűrés; erről is volt szó.
  • distinct: a többszörös elemeket szűri ki; erről is volt már szó.
  • flatMap(): ha a folyam összetett, pl. listák listája, vagy tömbök tömbje, és mi az elemeken szeretnénk végigiterálni, akkor ezt a függvényt használhatjuk a struktúra "kivasalására". Pl. ha az arrayOfArrays egy string tömbökből álló tömb (pl. String[][] arrayOfArrays = new String[][]{{"one", "two"}, {"three", "four", "five"}, {"six"}, {"seven";}}), akkor az Arrays.stream(arrayOfArrays).flatMap(x -> Arrays.stream(x)) egy olyan folyamot hoz létre, amely az egyes elemeket halad végig (a példában "one", "two", "three", "four", "five", "six", "seven").
  • limit(): a folyam hosszát lehet vele behatárolni. Példát lejjebb látunk majd.
  • peek(): egy műveletet lehet segítségével végrehajtani, pl. a peek(s -> System.out.println(s)) kiírja az elemeket. Mivel ez nem végművelet, ténylegesen csak akkor fogja kiírni, ha egy végművelet aktiválódik.

Végműveletek

A végműveleteket is a teljesség igénye nélkül mutatom be:

  • forEach(): mindegyik elemen végrehajt egy műveletet. Erre láttunk már fent példát. Vegyük észre a különbséget a peek() és a forEach() között: az előbbi csak akkor hajtódik végre, ha egy végművelet azt kiváltja, míg ez utóbbi önmagában kiváltja azt. A peek() tehát leginkább hibakereséshez használható, pl. egy filter() művelet előtt.
  • count(): megszámolja a folyamban levő elemeket.
  • collect(): paraméterként ún. Collector-t kap, amit végrehajt. Ennek segítségével tudjuk hagyományos adatszerkezetekké alakítani a folyamot, pl. a collect(Collectors.toList()) listává alakítja.
  • toArray(): tömbbé alakítja a folyam elemeit.
  • anyMatch(), allMatch() és noneMatch(): igaz értékkel tér vissza, ha a paraméterül átadott feltételnek legalább egy elem, az összes elem megfelel, ill. egyik elem sem felel meg.
  • min() és max(): visszaadja a folyam legkisebb ill. legnagyobb elemét.
  • findFirst() és findAny(): az első ill. egy tetszőleges elemmel tér vissza.
  • reduce(): összekombinálja a folyam elemeit. Bináris operátort vár paraméterül, melynek a paraméterei és a visszatérési értéke is megfelel a folyam elemeinek típusával. Működése a követező: veszi a folyam első két elemét, végrehajtja rajtuk a műveletet, majd az eredményen és a következő elemen szintén, egészen addig, amíg el nem fogynak az elemek. Opcionálisan átadhatunk egység elemet is; ez esetben azzal és az első elemmel kezdi a műveletet. Lássunk egy példát, mely összeszorozza a folyamban található számokat!
Stream<Integer> ints = Stream.of(5, 3, 7, 8, 2);
Optional<Integer> result = ints.reduce((a, b) -> a * b);
System.out.println(result.get());

Az eredmény típusa Optional<Integer>, ami szintén Java 8 újítás. Segítségével - ahogy a nevéből következtethetünk - opcionális értékeket tudunk létrehozni. Sokkal elegánsabb, mint a null érték ellenőrzése. Néhány fontosabb eljárás:

  • get(): visszaadja a tárolt értéket; ezt már láttuk,
  • boolean isPresent(): lekérdezi, hogy van-e benne tárolt érték.
  • orElse(other): ha jelen van az érték, akkor azt adja vissza, egyébként a paraméterül átadottat.

Folyamok létrehozásának a módjai

Folyamot többféleképpen létrehozhatunk, melyek közül néhányat megnézünk:

  • A Collection interfész tetszőleges megvalósításán a stream() eljárást meghívva, melyre fent láthattunk példát.
  • Tömbből, a következőképpen: Stream.of("apple", "banana", "orange", "banana", "apple") vagy így: Arrays.stream(array).
  • Generálással: pl. a Stream.generate(() -> Math.random()).limit(10) létrehoz 10 véletlen lebegőpontos számot 0 és 1 között.
  • Iterálással: egy képlet alapján generálja a következőt az előzőből. Pl. a Stream.iterate(2, (Integer n) -> n * n).limit(5) a 2 négyzeteinek a négyzeteit hozza létre (2, 4, 16, 256, 65536).

Saját Collector

Amint arról már szó volt, egy folyamban összesen egy végművelet lehet. Most tegyük fel, hogy milliárdnyi elemet tartalmaz a folyam; a lényeg, hogy nem lehetséges (vagy legalábbis nem gazdaságos) memóriában tárolni, valamint hosszú ideig tart feldolgozni. Tegyük fel, hogy a minimum és maximum elemet szeretnénk meghatározni egyszerre. Külön-külön ezt lehet beépített eljárással, de ezt alapból csak úgy tudjuk megtenni, hogy készítünk másolatot a folyamról, és így kétszer végigfutunk rajta. (Ráadásul ugyanazt kétszer nem is lehet elindítani.) Másik, sokkal hatékonyabb megoldás az, ha saját Collector megvalósítást hozunk létre, és azt átadjuk a collect() eljárásnak.

Saját Collector írása nem egyszerű; álljon itt példaként a fenti probléma általános megvalósítása:

import java.util.Comparator;
import java.util.stream.Collector;
import java.util.stream.Stream;
 
class Stats<T> {
    private int count;
    private Comparator<? super T> comparator;
    private T min;
    private T max;
 
    public Stats(Comparator<? super T> comparator) {
        this.comparator = comparator;
    }
 
    public int count() {return count;}
    public T min() {return min;}
    public T max() {return max;}
 
    public void accept(T val) {
        if (count==0) min = max = val;
        else if (comparator.compare(val, min) < 0) min = val;
        else if (comparator.compare(val, max) > 0) max = val;
        count++;
    }
 
    public Stats<T> combine(Stats<T> that) {
        if (this.count == 0) return that;
        if (that.count == 0) return this;
 
        this.count += that.count;
        if (comparator.compare(that.min, this.min) < 0) this.min = that.min;
        if (comparator.compare(that.max, this.max) > 0) this.max = that.max;
 
        return this;
    }
 
    public static <T> Collector<T, Stats<T>, Stats<T>> collector(Comparator<? super T> comparator) {
        return Collector.of(
            () -> new Stats<>(comparator),
            Stats::accept,
            Stats::combine,
            Collector.Characteristics.UNORDERED,
            Collector.Characteristics.IDENTITY_FINISH
        );
    }
 
    public static <T extends Comparable<? super T>> Collector<T, Stats<T>, Stats<T>> collector() {
        return collector(Comparator.naturalOrder());
    }
}
 
public class CustomCollectorExample {
    public static void main(String[] args) {
        Stream<Integer> numberStream = Stream.of(5, 3, 8, 4, 7, 5);
        Stats<Integer> result = numberStream.collect(Stats.collector());
        System.out.println(result.min() + ", " + result.max());
    }
}

A Java 8 hatása a Java Collection API-ra

A Java 8-ban a funkcionális programozási elemek bevezetése és a folyamok bevezetése mellett a Java Collection API is kapott egy komolyabb ráncfelvarrást, melyből megnézünk néhány példát.

  • forEach(): végigiterál az elemeket, és végrehajt egy műveletet.
  • removeIf(): ha adott feltétel teljesül, akkor (helyben) törli az elemet.
  • replacAll(): az összes elemet kicseréli
  • A map-ben getOrDefault(): ha a map tartalmazza a kulcsot, akkor a hozzá tartozó értéket adja vissza, egyébként egy alapértelmezett értéket. Ez az utasítás egy gyakran előforduló több soros kódrészletet vált ki.
  • A map-ben putIfAbsent(): csak akkor adja hozzá a kulcs-érték párt, ha még nem volt benne.

Lássunk ezekre is egy példát!

import java.util.*;
 
public class Java8CollectionAPI {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<String>(Arrays.asList("apple", "banana", "peach", "banana"));
        fruits.removeIf(f -> f.startsWith("ban"));
        fruits.replaceAll(f -> f.toUpperCase());
        fruits.forEach(f -> System.out.println(f));
    }
}

Megjegyzés: az inicializálásnál nem elég csak az Arrays.asList(), mert az egy olyan szerkezetet eredményez, mely nem támogatja a helyben törlést.

Összegzés

A Java 8-ban komoly előrelépés történt a folyamok világába. Azzal, hogy a Java Collection API-ba is bevezették az új elemeket, kifejezetten jól olvasható és tömör kódot tudunk készíteni. Ugyanakkor komoly sajnos hiányosságok is maradtak. A legfontosabb talán az, hogy a folyamok természetszerűleg végtelenek: folyamatosan jönnek az elemek, melyeket fel kell dolgozni, és például nem definiálható az a fogalom, hogy utolsó elem. Márpedig a Java stream-ek esetén ez a fogalom nélkülözhetetlen. Ami leginkább hiányzik, az talán a kötegelt feldolgozás: pl. százasával vehetné az elemeket, és azon belül értelmezné pl. az utolsó elemet, de amúgy futhatna a végtelenségig. Ezen kívül nincs természetes integráció a gyakori folyam megvalósításokkal, pl Apache Kafka vagy Event Hubs.

A funkcionális programozáshoz hasonlóan ez esetben is elmondható tehát, hogy komoly előrelépéseket tettek, de még van hová fejlődni.

Hálózat

TODO: E-mail is

Reflection

Lokalizáció

Távoli metódushívás

RMI, REST, WS, …

Grafikus felhasználói felület

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License