Sunday, November 20, 2016

Java - nyelvi alapok

Futtatni vagy fejleszteni?

  • JRE = Java Runtime Environment, csak futtatni
  • JDK = Java Development Kit, fejleszteni is, de ebben van egy JRE is

Fejleszteni, de milyen környezetben?

Rengeteg IDE-s fejlesztőeszköz van a világon, NetBeans, Eclipse, IntelliJ, Anjuta (és még az AndroidStudio is ide tartozik), amik számos hasznos dologgal segítenek minket, de közös nevezőként akár egy sima szövegszerkesztővel és a parancssoros fordítóval és futtatóval is el lehet boldogulni.
Pláne nagyobb és összetettebb rendszereknél hasznosak az IDE-k, a választás ízlés dolga, de az itteni példák bárhol működni fognak.

A futtatási koncepció

A Java fordított nyelv (szemben az értelmezett nyelvekkel), azaz a forráskódból egy bináris program fog készülni, és ezt lehet majd végrehajtani. Azért viszont, hogy ez különböző platformok között hordozható legyen, nem valamely architektúra gépi kódjára fordul le, hanem egy célspecifikus virtuális gépére (JVM - Java Virtual Machine), és ennek a virtuális gépnek van kvázi emulátora majd' minden architektúrához.
Maga a JVM egyébként kvázi szabvánnyá nőtte ki magát, olyannyira, hogy vannak egyéb nyelvek is, amik JVM binárisra fordulnak, annak ellenére, hogy közük sincs a Javához :D.
A JRE tulajdonképpen a JVM-et tartalmazza, és emellett még a nyelvhez adott támogató könyvtárat (ez tud olyasmiket, mint pl. file-kezelés, adatszerkezetek, stb.), meg a dokumentációt.
A forrásprogramok hagyományos kiterjesztése a .java, a lefordított binárisoké a .class, illetve hogy könnyebb legyen az összetartozó .class-ainkat (package) együtt kezelni, ezeket .jar (Java Archive) -okba tudjuk összetömöríteni.
A .jar amúgy technikailag .zip, csak a .class-okon kívül egyéb meta-adatokat is tartalmaz, mint pl. a package neve, verziója, opcionálisan digitális aláírás, stb.
(Lesz még egy fajta file, a .properties, ami 'kulcs: érték' formában utólagosan a user által módosítható beállításokat tartalmazhat, és amihez a standard library jó támogatást nyújt, így nem kell nekünk saját config-file-kezelést megírnunk. De erről majd a maga idejében.)

A fejlesztési koncepció

Az az alapgondolat, hogy maga a Java csak egy nyelv, bizonyos tulajdonságokkal és képességekkel, de a beépített nyelvi elemek minden specifikusságtól mentesek legyenek,
illetve ehhez legyenek gazdag és sokrétű, előre megírt osztály-könyvtárak, amik tartalmaznak rengeteg olyan dolgot, ami a sima nyelvi elemekkel megvalósítható, és amire általában szükség szokott lenni.
Tehát pl. az elemi típusok (karakter, egész, logikai, stb.), az adat-konstrukciók (tömb, struktúra/osztály), a programkonstrukciók (elágazások, ciklusok, osztályok koncepciója) azok nyelvi elemek, de a String típus, a File, a List és ilyesmik már külső osztálykönyvtárakból jönnek.
Elvileg ezek nélkül, pusztán a nyelvi elemekkel is lehetne dolgozni, csak kár volna újraírni és újratesztelni mindent :D
Az osztálykönyvtárak hierarchikusan vannak szervezve, ahol az egyes szintek neveit ponttal választjuk el, azaz pl. a 'java.*' az alap Java osztálykönyvtár, ezen belül a 'java.io.*' a be/kimenettel kapcsolatos dolgok helye, a 'java.util.*' a mindenfél segéd-cuccé, az 'org.apache.http.*' az Apache által adott osztálykönyvtárban a HTTP protokollal kapcsolatos dologké, és így tovább.
Az ütközések elkerülése végett a 'java.*' és a 'javax.*' a JRE-ben gyárilag benn lévő osztálykönyvárak, mindeni más pedig legyen szíves a megfordított domain nevével kezdeni a könyvtárainak a neveit (azaz 'apache.org' -> 'org.apache.*').

Melyik verzióban mi minden van?

  • Java SE = Standard Edition, a nyelv maga, meg a standard osztály-könyvtár
  • Java ME = Micro Edition, kb. ami nem kötődik GUI-hoz, ill. a szűk minimum
  • Java EE = Enterprise Edition, web services, servlet, jsp, ejb, persistence, transaction, minden, ami csak van
Mi most a Java SE-vel fogunk kezdeni, a nyelv bemutatásához az pont alkalmas lesz.

A JDK telepítése

Ami tehát nekünk most kelleni fog mindenképpen: Java SE Downloads / JDK

Települ pl. c:\Program Files\Java\jdk1.8.0_111\ alá, a benne lévő fejlesztőeszközök az ez alatti bin\-ben lesznek, a JRE pedig az ez alatti jre\-be kerül.
Tehát a PATH-hoz érdemes (pontosvesszőkkel elválasztva) hozzáadni ezt a c:\Program Files\Java\jdk1.8.0_111\bin-t és a c:\Program Files\Java\jdk1.8.0_111\jre\bin-t.
Ezt ellenőrizhetjük is:
C:\>java -version
java version "1.8.0_111"
Java(TM) SE Runtime Environment (build 1.8.0_111-b14)
Java HotSpot(TM) Client VM (build 25.111-b14, mixed mode)

C:\>javac -version
javac 1.8.0_111

C:\>jar
Usage: jar {ctxui}[vfmn0PMe] [jar-file] [manifest-file] [entry-point] [-C dir] files ...
Options:
-c create new archive
...

Ezzel a három leg-alapvetőbb parancsot is megemlítettük:
  • A 'java' a JVM futtató parancsa, amivel a .class file-okat fogjuk végrehajtani
  • A 'javac' a fordító, ami a .java forrásokból .class binárisokat készít
  • A 'jar' a csomagoló, amivel .class binárisokat foghatunk össze .jar archívumokká

Az első program

Csináljunk egy próba-könyvtárat és tegyük le bele az első teszt-programunkat HelloWorld.java néven:
class HelloWorld
{
    HelloWorld(String[] args) {
        System.out.println("Hello World!");
        for (String s : args) {
            System.out.println("arg: " + s);
        }
    }

    public static void main(String[] args) {
        new HelloWorld(args);
    }
}
Ezt fordítsuk is le, és futtassuk:
C:\Temp\dev_java>javac HelloWorld.java
C:\Temp\dev_java>java HelloWorld alma korte szilva
Hello World!
arg: alma
arg: korte
arg: szilva

Nos, valamit már csinál, viszont ahhoz, hogy el tudjuk mondani, hogy mi mit csinál és miért úgy, ahhoz először pár nyelvi koncepciót meg kell néznünk, úgyhogy ezt most tegyük félre egy kicsit.

Nyelvi koncepciók

Mindenek előtt, a Javában minden, kívülről elérhető típusnak saját file-ban kell helyet kapnia, és a file elérési útvonala 1-1 viszonyban van a típus nevével, azaz a 'HelloWorld' típusnak a 'HelloWorld.java'-ban van a helye.
Emellett még lehetnek egyéb segéd-típusok is ebben a file-ban, de azok kívülről nem lesznek elérhetőek.

Elemi típusok és operátorok

A Java struktúrált, és azon belül objektum-orientált nyelv, erősen típusos, tehát az adatokat valamilyen típusú változókban tarthatjuk, ezekből adat-konstrukciókat építhetünk fel, a végrehajtás pedig elemi utasításokból épül fel, amikből program-konstrukciókat szervezhetünk. (Tehát nem pl. SQL vagy Prolog :D...)

A nyelv minden eleme kis/nagybetű-érzékeny, azaz az 'ize', az 'IZE' és az 'Ize' három különböző dolog. Szokás a változókat kisbetűvel kezdeni, az összetett típusok neveit naggyal, a konstansokat pedig végig naggyal írni, így már ránézésre is láthatjuk, hogy mi micsoda.

Az elemi típusok:
  • byte (1 byte-os előjeles egész, -128..127), pl. -42, 0x2a,
  • short (2 byte-os, -32768..32767)
  • int (4 byte-os, -2G ..2G - 1)
  • long (8 byte-os, -sok .. sok -1)
  • float (4 byte-os valós, kb. 7 jegy pontossággal), pl. 3.1415f, 6.022e23
  • double (8 byte-os valós, kb. 16 jegy pontossággal), pl. 3.1415, 1.38e-23
  • boolean (logikai), pl. true, false
  • char (unicode karakter), pl. 'а' , 'Я', '\n' (sorvégjel) '\u263a' (smiley)
És mi a helyzet a stringekkel, ha már a példában használtuk is?
Nos, a 'String' az összetett típus, a rendes polgári neve amúgy 'java.lang.String', ami azt jelenti, hogy ő a 'java.lang' osztálygyűjteményben lakik, és ebből azt is láthatjuk, hogy ezen gyűjtemény annyiban speciális, hogy az ő típusaira röviden is hivatkozhatunk.

Visszatérve, amikor egy adott típusból változót szeretnénk megadni, akkor a kb. a C szintaxis szerint történik:
int x, y, sum; // ez itt komment a sor végéig
boolean isValid, containsSpace; /* ez pedig a bezárásáig */
Tehát a változó-deklaráció a 'típusnév var1, var2, ..., varN;' formában történik, a változónevek pedig nem kezdődhetnek számmal, nem tartalmazhatnak szóközt vagy valamely operátor-karaktert, nem egyezhetnek nyelvi kulcsszavakkal, egyébként bármit tartalmazhatnak, akár arab karaktereket is.

Kommenteket a forráskódba a '//'-től sor végéig ill. '/*'-tól '*/'-ig terjedő formával tehetünk - pont mint C++-ban.

Szokás a változókat kisbetűvel kezdeni és beszédes neveket adni nekik, amelyeknél az összetett neveket camel case-szerűen tagoljuk, azaz az első betű kicsi, a többi szókezdő viszont nagy. (Elsőre furcsa, de egy idő után rááll a szem...)

Az egyes elemi típusokból készíthetünk tömböket, amik viszont külön típusnak számítanak,
azaz
float[] sampleWeight;

Fontos, hogy ezzel -az elemi típusú változókkal szemben- még csak azt mondtuk meg, hogy a 'sampleWeight' egy valós tömb lesz, de még nem hoztuk azt létre.
sampleWeight = new float[23];
sampleWeight[0] = 11.1;
sampleWeight[1] = sampleWeight[5] = 2.4;
sampleWeight[2] = (sampleWeight[0] + sampleWeight[5]) / 2.0f;
isValid = (sampleWeight[2] > 1.0f) && !(sampleWeigth[0] <= 100.0f);

Ebből látjuk, hogy új tömböt (meg majd új bármit is) létrehozni a 'new típusnév' formában tudunk, a tömb-elemekre pedig a szokott formában hivatkozhatunk.

Az értékadást eszerint a sima '=' jelöli, az aritmetikai operátorok között a szokásos +, -, *, / (egész osztás), % (maradék) működik, az összehasonlító operátororok == (egyenlő-e), != (nem egyenlő-e), <, >, <=, >=, a logikai operátorok ! (tagadás), && (és), || (vagy), és a zárójelezéssel lehet a precedenciát felülbírálni.

Működik a C-beli láncolt értékadás, azaz a 'v1 = v2' értékadásnak mint kifejezésnek is van értéke, nevezetesen a v2.

A bit-szintű műveletek szintén a C szerintiek: & (bináris és), | (bináris vagy), ^ (bináris kizáró vagy), ~ (bináris invertálás), << (balra shift), >> (jobbra shift), illetve egy újdonság: >>> (előjel nélküli jobbra shift).

Működik egyébként a C-ből ismerős prefix és postfix növelés és csökkentés:
  • 'változó++': növeld a változót eggyel, a kifejezés értéke a növelés előtti érték
  • '++változó': növeld a változót eggyel, a kifejezés értéke a növelés utáni érték
  • 'változó--': csökkentsd a változót eggyel, a kifejezés értéke a csökkentés előtti érték
  • '--változó': csökkentsd a változót eggyel, a kifejezés értéke a csökkentés utáni érték
Hasonlóképpen működnek a műveletes értékadások:
  • 'v1 += v2': v1 = v1 + v2
  • 'v1 -= v2': v1 = v1 - v2
  • stb.
És az egyetlen három operandusú művelet:
  • 'isValid ? x : y'
    Ha az isValid igaz, akkor a kifejezés értéke x, különben y
Egyébiránt a 'new' is operátor: a 'new típusnév' értéke egy, az adott típusból létrehozott új példány :D. Lesz még 4 db operátor (metódushívás, attribútum-elérés, típus-ellenőrzés és típus-konverzió), azokról viszont később esik majd szó.

Blokkok

A C-ből ismert módon a { }-ek közötti rész egységnek számít, azaz mindenhol állhat, ahol egyedi utasítás állhat, és a tartalma szépen szekvenciálisan hajtódik végre (kivéve...)
Az egyetlen említésre méltó dolog az, hogy a blokkokban bárhol szintén lehet változókat deklarálni, amik a blokkból kilépéskor automatikusan megsemmisülnek. Ha egy blokkbeli változónak ugyanaz a neve, mint egy blokkon kívülinek, akkor a belső 'elfedi' a külsőt.

Vezérlési struktúrák

Egy az egyben a C mintájára zajlik:

Feltételes végrehajtás

if (logikai_kifejezés)
    ha_igaz_akkor_teendő;

if (logikai_kifejezés)
    ha_igaz_akkor_teendő;
else
    különben_teendő;

Érték szerinti elágazás

switch (egész_vagy_enum_kifejezés) {
    case érték1:
        teendő1;
        break;

    case érték2:
        teendő2; // szándékos fallthrough
    
    default:
        különben_teendő;
}

Feltétel-vezérelt ciklusok

while (logikai_kifejezés)
    amíg_igaz_teendő;

do {
    amíg_igaz_de_legalább_egyszer_teendő
} while (logikai_kifejezés);

C-szerű ciklus

for (kezdeti_művelet; bennmaradási_feltétel; léptetés)
    teendő;

for (int i = 1; i < 2048; i = 2*i)
    System.out.println(i);

Iteráló ciklus

for (változó: tömb_vagy_collection)
    teendő;

for (String s: args)
    System.out.println("arg: " + s);

Visszatérés függvényből/metódusból

return; // void metódusból
return visszatérési_érték; // kifejezés-jellegű metódusból

Kilépés for/while ciklusból

while (...) {
    ...
    break;
    ...
}

kulso_cimke: while (...) {
    while (....) {
        ...
        break kulso_cimke;
        ...
    }
}

For/while ciklus folytatása

while (...) {
    ...
    continue;
    ...
}

kulso_cimke: while (...) {
    while (....) {
        ...
        continue kulso_cimke;
        ...
    }
}

Kivétel-kezelés

Technikailag ez is ide tartozna, de logikailag sokkal később, mert kell hozzá pár egyéb koncepció ismerete.

Osztályok és objektumok

A tömböt már néztük, a másik nevezetes típus-konstrukciót, a struktúrát itt osztálynak hívják, mert pusztán struktúra a Javában nincsen.
class Person
{
    String givenName, familyName;
    int height, weight;
}
Ezzel definiáltunk egy új összetett típust, amiből példányokat szintén a 'new'-val hozhatunk létre:
Person jane, john;
jane = new Person();
jane.givenName = "Jane";
jane.height = 165;
john = new Person();
john.givenName = "John";
john.height = 176;

Mint láthatjuk, ez a 'new' egy kicsit másabb a tömbös new-nál, itt () zárójelpár van mögötte.
Hasonlóképpen az is kiderült, hogy az objektumpéldányok attribútumaira a 'változó.attribútum' formában hivatkozhatunk - ez a '.' a már említett 4 maradék operátor egyike :D.

Komoly probléma persze, hogy így az objektum adatainak a kezelését, beleértve az inicializálást is, a hívóra bíztuk, ami nem túl szerencsés.
Az osztály és a struktúra egyik leglényegesebb különbsége, hogy a struktúra csak összetartozó adatokat fog egybe, az osztály viszont az ezeket kezelő kódot is tartalmazza.
Például amíg egy String példány (ami maga is egy objektum) nincs inicializálva, addig egy speciális értéket a 'null'-t tartalmaz, és bármit próbálnánk kezdeni vele, az futási hibát eredményezne. Ha viszont a hívóra bízzuk az adatmezők kezelését, akkor semmi garancia, hogy ez nem következik be, például a fenti kódban a példányok 'givenName' mezői kaptak értéket, de a 'familyName'-ek nem.
Ezt azzal tudjuk megelőzni, ha megadjuk azt a kódot, amivel ezen típus egy-egy új példányát inicializálni kell: a konstruktort.
class Person
{
    String givenName, familyName;
    int height, weight;

    public Person() {
        givenName = familyName = "N/A";
        height = weight = 0;
    }
}
Így amikor valaki leírja, hogy 'x = new Person()', akkor a fenti Person.Person() fog lefutni, és az korrektül inicializál mindent.
Mint láthatjuk, az osztály attribútumaira a saját metódusaiban csak szimplán az attribútumnévvel hivatkozhatunk, ez az épp aktuális példány attribútumát jelenti.
Magára az aktuális példányra a 'this' kulcsszóval hivatkozhatunk, tehát pl. írhattuk volna azt is, hogy 'this.givenName = ...'.
Persze nem csak az inicializálás az egyetlen művelet, amit megadhatunk:
class Person
{
    // ...
    public String toString() {
        return givenName + " " + familyName + " (" + height + " cm, " + weight + " kg)";
    }
    // ...
}
// ...
System.out.println(jane.toString());
System.out.println(john);

A fenti 'toString()' egy kicsit mágikus név, abban az értelemben, hogy ha valahová String-re lenne szükség (mint pl. a System.out.println(String) kiírásban), de egy objektum-példányt adtunk meg, akkor az implicite konvertálódik a toString() metódusának segítségével.
Ez kicsit hasonlít ahhoz a mágiához, ahogy Stringeket a '+' jellel össze tudunk fűzni, ez szintén 'syntactic sugar', elvileg ez nem volna helyes kifejezés.
(Természetesen a 'System' is valójában 'java.lang.System', ami ugye szintén egy mágia :D
Sokan felróják az ilyesmit a Java nyelvnek, nem is teljesen alaptalanul...)

Objektumok megszűnése

Konstruktort már írtunk, mi a helyzet az ellenpárjával, a destruktorral? Olyan nincsen!
A Javában minden objektum-példány implicite tartalmaz egy referencia-számlálót, ami nő eggyel, amikor a példány címét egy új változónak értékül adjuk, és csökken, amikor az a változó egy új értéket kap. Például a fenti példában a két Person-példány refcountja 1-1, mert mindegyikre 1 változó (a jane és a john) hivatkozik.
De ha ezt írnánk:
// ...
Person x = jane;
// a 'jane' által hivatkozott példány refcountja itt már 2
Person y = john
// most már a 'john' által hivatkozotté is
x = y;
// a 'jane' által hivatkozotté most megint 1, a 'john'-é pedig 3
jane = null;
// a korábban a 'jane' által hivatkozott példány refcountja már 0!
Azt hiszem, érthető a dolog :D.
Amikor elvesztettük/eldobtuk az utolsó hivatkozást is egy példányra, akkor annak a refcountja 0-ra csökken, és már ha akarnánk, se tudunk többé rá hivatkozni, így ez fölöslegessé vált.
Ilyenkor ez a példány a 'szemétkosárba' kerül, és majd valamikor, amikor a JVM épp ráér, akkor szép komótosan elkezdi őket felszabadítgatni. Például a most megszüntetett Person példánybeli két String-példány refcountjait csökkenti, és mivel azok is 0-ra futottak, szintén mennek a szemétbe.
Persze az élet kissé bonyolultabb, pl. ha a Person-t kiegészítjük így:
class Person
{
    //...
    Person spouse;
}
// ...
Person jane = new Person(...);
Person john = new Person(...);
// mindkét példány refcountja 1-1
jane.spouse = john;
// a 'john' példány refcountja 2, mert ennyien hivatkoznak rá: john és jane.spouse
john.spouse = jane;
// most már a 'jane' példány refcountja is 2
john = jane = null;
// mindkét példány refcountja 1-1 (egykori_john.spouse és egykori_jane.spouse)
Akkor most ezek a végtelenségig életben tartják egymást, vagy mégsem?
A felszabadító mechanizmus elég okos ahhoz, hogy az ilyen köröket felismerje, azaz valójában amihez a főprogramból nem lehet eljutni, azt szabadítja fel, a refcount csak könnyítheti a döntést.
Szóval ha egy példányra már nincs szükségünk, akkor csak nullázzunk ki minden rá mutató referenciát, és a többit a Garbage Collector (GC) majd elintézi.

Paraméterek a metódusoknak és polimorfizmus

Természetesen a hívási paraméterek fogalma itt is megvan, sőt, arra is van módunk, hogy ugyanazon nevű metódust más és más paraméterlistával újra definiáljuk:
// ...
public Person(String gn) {
    givenName = gn;
    familyName = "N/A";
    height = weight = 0;
}
public Person(String givenName, String familyName) {
    this.givenName = givenName;
    this.familyName = familyName;
    height = weight = 0;
}
public Person(Person other) {
    givenName = other.givenName;
    familyName = other.familyName;
    height = other.height;
    weight = other.weight;
}
//...
Person jane = new Person("Jane");
Person john = new Person("John", "Doe");
Person john2 = new Person(john);
A híváskori paraméterlista egyértelműen azonosítja, hogy melyik esetben melyik konstruktort kell hívni. Természetesen ugyanez bármely metódussal ugyanígy működik.
Szokásos trükk, hogy ha valamelyik paraméter és valamelyik attribútum neve ütközik, akkor az önmgában álló név a paramétert jelenti, ott az attribútumra pedig a 'this.valami' formában hivatkozhatunk.
A harmadik konstruktor, ami ugyanezen típus másik példányáról inicializál, az nevezetes, és copy-constructornak hívják, ahogyan a paraméter nélküli konstruktor neve default constructor.

Öröklődés

"Minden bogár rovar, de nem minden rovar bogár" - ismerős?
A példánknál maradva, minden sofőr ember, de nem minden ember sofőr:
class Driver extends Person
{
    String licenseNumber;

    public String toString() {
        return super.toString() + ", lic=" + licenseNumber;
    }

    public Driver(String gn, String sn, String l) {
        super(gn, sn);
        licenseNumber = l;
    }

    public string getLicenseNumber() { return licenseNumber; }
}
// ...
Driver john = new Driver("John", "Doe", "12341234");
Person johnp = john;

john.height = 176;
System.out.println("john=" + john);
System.out.println("johnp=" + johnp);
Ennek az eredménye:
C:\Temp\dev_java>java PersonTest
john=John Doe (176 cm, 0 kg), lic=12341234
johnp=John Doe (176 cm, 0 kg), lic=12341234
Tehát a sofőr is ember, csak neki van jogosítványszáma is, itt Person-t ősosztálynak, a Driver-t leszármazott osztálynak hívjuk, és az erre vonatkozó szintaxis a 'class leszármazott extends ős'.
A Driver-példány inicializálásához három string kell, az első kettő az ősosztály konstruktorához használódik ('super(gn, sn)'), és ehhez nagyon hasonlóan a 'toString()' is felhasználja az ősosztály 'toString()'-jét, meglehetősen hasonló jelölésmóddal.
A 'Person johnp = john' sikeressége mutatja, hogy a Driver egyben Person is, ugyanis az ellenkező irány, pl. egy 'john = johnp' hibaüzenetet adna:
error: incompatible types: Person cannot be converted to Driver
john = johnp;

És egyúttal azt az amúgy fontos dolgot is láthattuk, hogy a metódusok konkrét példányokhoz kötődnek, tehát a System.out.println("johnp=" + johnp)-nél hiába hogy Person típusú johnp-ként hivatkoztunk a Driver-példányunkra, akkor is a Driver.toString() hívódott meg.
Ezt hívják virtuális metódusnak, C++-ban ezt külön kéne jelölni, de Javában minden metódus virtuális.

Szintén fontos, hogy örökölni csak egyetlen típusból lehet, mégpedig azért, hogy megelőzzünk egy ütközést: ha lehetne pl. két ős, és az egyik ős definiál egy 'x' attribútumot 'int'-ként, a másik ős meg 'String'-ként, akkor a leszármazottban milyen típussal szerepeljen, és mi légyen a másik ősnek az ezt piszkáló metódusaival? Nincs jó válasz, tehát nincs többes öröklődés.
Megjegyzendő még, hogy van egy 'Object' nevű típus, ami minden osztály-típusnak implicit őse, azaz mindenki belőle származik, anélkül, hogy ezt explicite kiírná. (Megint egy mágia.)

Az öröklődés jelentése

Az öröklődés konkrétan új információval való bővülést jelent, sem többet, sem kevesebbet.
A valós életben ez helyzettől függően jelenthet akár specializációt, akár generalizációt, úgyhogy a 'mit származtassunk miből' tervezési kérdésnél az 'információval bővülést' kéretik nézni!

Öröklődés mint specializáció

Az Ember típus tartalmaz nevet, életkort, testsúlyt, magasságot, a Sofőr típust pedig az Ember típusból származtathatjuk (a 'jogosítvány száma' lesz a bővülő információ).
Jelentését tekintve a Sofőr pedig az Ember specializációja, mert minden Sofőr egyben Ember is, de nem minden Ember Sofőr.

Öröklődés mint generalizáció

A Négyzet típus tartalmaz oldalhosszot és színt, a Téglalap típust pedig a Négyzet típusból származtathatjuk (a 'másik oldal hossza' lesz a bővülő információ).
Jelentését tekintve a Téglalap pedig a Négyzet generalizációja, mert bár nem minden Téglalap Négyzet, de minden Négyzet egyben Téglalap is.

Típus-ellenőrzés és -konverzió

A fenti példában 'johnp' egy Person-referencia, tehát neki nem tudnánk pl. a licenseNumber-ére hivatkozni, mert a Person-nak olyanja nincsen. Megtehetnénk, hogy áterőltetjük Driver-ré, de ez katasztrófához vezet, ha esetleg mégis csak egy sima Person-ra hivatkozott, ami nem Driver. Tehát először ellenőrizni kellene:
if ((johnp != null) && (johnp instanceof Driver)) {
    Driver johnd = (Driver)johnp;
    System.out.println("lic: " + johnd.licenseNumber);
}
Mint láthatjuk, a 'változó instanceof típus' kifejezést kerestük az ellenőrzés céljára, és a '(típus)változó'-t a típus áterőltetésére.

Absztrakt osztályok

Ha egy ős-osztály valamely metódusát nem valósítjuk meg (mert pl. nem lehet értelmesen), de a leszármazottainak elő akarjuk írni, hogy nekik felül kelljen definiálni, akkor az ilyen osztályt hívjuk absztraktnak:
abstract class Shape
{
    abstract public double getArea();
}
class Circle extends Shape
{
    public double getArea() { return r*r*Math.PI; }
}
Természetesen absztrakt osztályt nem lehet példányosítani, és ha származtatunk belőle, akkor abban vagy minden absztrakt metódust meg kell valósítani, vagy a leszármazott osztálynak is absztraktnak kell lennie.

Láthatóság

A fenti példákban pár helyen előfordult a 'public' szó, és ezt még nem veséztük ki.
Bevezethetünk mi olyan konstruktorokat, amilyeneket csak akarunk, ha a hívónak megengedjük, hogy a példányaink attribútumait kedvére elpiszkálja, pl. 'john.givenName = null'. Ezt csak úgy tudnánk kizárni, ha a védendő atttribútumainkat csak nekünk lenne jogunk állítgatni.
class Person
{
    private String givenName, familyName;
    // ...
Ezután az adott attribútumot csak az osztály saját metódusai tudják elérni, más senki sem.
Ha egy metódust teszünk 'private'-té, akkor értelemszerűen csak az osztály többi metódusa fogja tudni meghívni.
Ehhez hasonló minősítő a 'public', ami mindenkinek elérhetővé teszi az adott attribútumot/metódust, illetve a 'protected', ami csak az osztálynak és a leszármazottainak.
Mi a helyzet azzal, ami se private, se protected, se public? Az az úgynevezett 'package' láthatóság, amikor a velünk egy osztálykönyvtárbeli típusok metódusai érhetik el, más nem.

Statikus metódusok és attribútumok

Ha valami olyan attribútumot vagy metódust szeretnénk létrehozni, ami nem az egyes objektumpéldányokhoz, hanem magához az osztálytípushoz kötődik, arra a 'static' kulcsszó alkalmas.
Ilyenek pl. a konstansok, vagy ha pl. a létrehozott példányokat szeretnénk megszámlálni:
class HtmlColour
{
    public static int RED = 0xff0000;
    public static int GREEN = 0x00ff00;
    public static int BLUE = 0x0000ff;
    private static int numInstances = 0;
    public static int getNumInstances() { return numInstances; }
    public HtmlColour() {
        numInstances++;
        // ...
    }
}
Ezekre példányosítás nélkül is hivatkozhatunk:
int x = HtmlColour.RED;
int y = HtmlColour.getNumInstances();
(Igazából a konstansok deklarációjában szokott még lenni egy 'final' kulcsszó, pl. 'public static final int RED = 0xff0000;', ami annyit tesz, hogy az adott változó/attribútum csak egyetlen egyszer kaphat értéket, onnantól read-only, és így a Java fordító ezzel tud optimalizálni.)

Egy kapcsolódó trükk:
Ha nem szeretnénk, hogy bárki tudja az osztályunkat példányosítani (mert pl. csak egyetlen példánynak van értelme belőle), akkor a konstruktort kívülről meghívhatatlanná tehetjük:
private Driver(...) {
    // ...
}

static Driver theDriver;

public static Driver get() {
    if (theDriver == null)
        theDriver = new Driver(...);
    return theDriver;
}
// ...

Driver x = Driver.get();

Ezt a kód-struktúrát hívja az irodalom 'Singleton pattern'-nek.

Interface-ek

Mint láthattuk, Javában nincs többszörös öröklődés, és jó okkal nincsen. Viszont helyenként igencsak praktikus volna, ha egy általános célú kódban csak valamilyen képességet szeretnénk feltételezni a kapott paraméterről, pl.:
abstract class Readable {
    abstract public char read(); // read one character
}
String readLineFrom(Readable x) {
    String s = "";
    while (true) {
        char c = x.read();
        if (c == '\n')
            return s;
        s = s + c;
    }
}
Ez egy sorvégjellel lezárt sorozatot olvasna be egy Stringbe bármiből, ami a Readable-ből származik és megvalósította az egy-karakter-olvasása műveletet. És ez így még működne is!
A gond akkor jön, ha van hasonló igény pl. egy 'Writable' típusra, és mi valami olyasmit szeretnénk megvalósítani, amit írni és olvasni is lehet (pl. file, hálózati kapcsolat, soros terminál, stb.):
class Terminal extends Readable, Writable { ... }
Terminal t;
String s = readLineFrom(t);
sayHelloTo(t);

Hát ilyen ugye nincsen, pedig az eset életszerű. Erre szolgál a Javának az interface fogalma:
Az interface valamelyest hasonlít az absztrakt osztályokra, csak
  • Az interface-nek nem lehetnek attribútumai, csak metódusai
  • Az összes metódus implicite public, ezt nem kell külön jelölni
  • A metódusokat nem kell külön absztraktnak sem jelölni, elég, ha nincsen törzsük
  • Az interface tartalmazhat statikus metódusokat és konstansokat is
  • Az interface-ből nem leszármazik valaki, hanem megvalósítja azt,értve ez alatt azt, hogy megvalósít minden, az interface-ben megadott metódust
  • Az interface-ben a metódusokhoz a 'default' kulcsszóval adhatunk meg alapértelmezett törzset, ami csak akkor lép érvényre, ha az interface-t megvalósító osztály azt nem valósítaná meg (pl. mert az interface bővült az osztály írása óta)
  • Interface-ek örökölhetnek egymástól
  • Ahol típus állhat, ott interface is, és oda illeszkedik bármilyen típus, ami megvalósítja az adott interface-t.
Tehát pl.:
interface Readable
{
    char read();
}
interface Writable
{
    bool write(char c);
}
class Terminal implements Readable, Writable
{
    public char read() { ... } // from Readable
    public bool write(char c) { ... } // from Writable
    // ...

Generikus típusok

Képzeljünk el egy verem-implementációt, amibe valamilyen típusú objektumokat pakolhatunk be, illetve ugyanezeket vehetjük ki onnak, lesz pl. egy 'void push(...)' meg egy 'Valami pop()' metódusa. No de mi legyen ez a 'Valami'?
Ha én embereket akarok tárolni, akkor Person, ha terminálokat, akkor viszont Terminal. Lehetne a végső közös ős, az Object, de akkor a kivett elemekkel nem könnyen lehetne bármit is kezdeni, amit az Object típus nem tud:
Object x = verem.pop();
x.write('Q'); // hiba: write()-ja pl. a Terminal-nak van, de az Object-nek nincs
Valahogy a listának meg kéne kapnia a típust is, amilyen objektumokat fogadnia és visszaadnia kell:
interface List<T>
{
    void push(T x);
    T pop();
}
List<Person> emberek = new List<Person>();
List<Terminal> terminalok = new List<Terminal>();
emberek.push(john);
Person x = emberek.pop();
A fenti példában a 'T' egy típus vagy interface helyén állt, és a kiértékeléskor az épp aktuális típussal helyettesítődött be.
Természetesen ez nem csak egybetűs lehet, és nem is csak egy lehet belőle, pl. a kulcs-érték párokat tartalmazó java.util.Map szignatúrája 'public interface Map<K, V>', ahol K és V a kulcs és az érték típusai.
Csak konvenció, de pár, egy db nagybetűből álló nevet bizonyos fajta típusok jelölésére használunk:
  • K = Key
  • V = Value
  • N = Number
  • E = Element
  • T = Type (általános)
  • S, U, V, ... = többi (általános) típus

Felsorolási konstans típusok

Ha pl. színeket akarunk megadni, akkor az eddigiek alapján egy típust vezetnénk be, és abban definiálnánk konstansokat (lásd pl. HtmlColour fentebb).
Van viszont erre egy cizelláltabb, erősebb nyelvi lehetőség, az '
enum':
public enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS (4.869e+24, 6.0518e6),
    EARTH (5.976e+24, 6.37814e6),
    MARS (6.421e+23, 3.3972e6),
    JUPITER (1.9e+27, 7.1492e7),
    SATURN (5.688e+26, 6.0268e7),
    URANUS (8.686e+25, 2.5559e7),
    NEPTUNE (1.024e+26, 2.4746e7);

    private final double mass; // in kilograms
    private final double radius; // in meters
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }
    private double mass() { return mass; }
    private double radius() { return radius; }
    // ...
Mint láthatjuk, egészen class-ként viselkedik, csak meg lehet adni a lehetséges értékek listáját is.

Package-ek

Láttuk, hogy mindenféle osztálykönyvtárak szép kompakt csomagokban jönnek, és nem ezernyi külön .class file-ban, hát ilyet mi is tudunk csinálni!
Válasszunk egy package nevet, pl. org.dyndns.fules.demo, és hozzunk létre egy ennek megfelelő könyvtárstruktúrát: org/dyndns/fules/demo.
Az egyes osztályainkat (Person, Driver, PersonTest) válogassuk külön-külön file-okba ezen könyvtárstruktúrában (Person.java, Driver.java, PersonTest.java).
Mindegyik ilyen file elején adjuk meg a package nevét, amihez tartozik:
package org.dyndns.fules.demo;
Forgassuk le őket és futtassuk a PersonTest-et:
javac org\dyndns\fules\demo\*.java
java org.dyndns.fules.demo.PersonTest
A .class file-ok a .java-k mellé keletkeztek, tehát még csak félúton vagyunk.
Ezt kissé póriasan, de már összefűzhetjük egy archive-ba, és el is lehet indítani:
jar cvf valami.jar org\dyndns\fules\demo\*.class
java -cp valami.jar org.dyndns.fules.demo.PersonTest
Bár ekkor még kézzel kellett megadni ClassPath-ként (-cp) a .jar-t, és az indítandó osztály nevét, amit be is lehetne ágyazni a .jar-ba:
jar cvfe valami.jar org.dyndns.fules.demo.PersonTest org\dyndns\fules\demo\*.class
java -jar valami.jar
Itt az 'indítandó osztály' annyit jelent, hogy amikor a JVM indul, akkor ennek az osztálynak keresi a 'public static void main(String[] args)' metódusát, amit meghív, paraméterként átadva a kapott parancssori argumentumokat.
Ha kívülről, más osztályokból szeretnénk ezt a csomagot használni, akkor
  • Egyrészt azok fordításánál a '-cp valami.jar' -ral a java .class-ok keresési útvonalához hozzá kell adni
  • Másrészt a használó .java file elején a kívánt osztályt be kell importálni, pl. 'import org.dyndns.fules.demo.Driver;'
Az import-nak van még két fajtája:
  • On-demand: 'import org.dyndns.fules.demo.*;' ekkor minden közvetlen ottani típus beinclude-olódik, amit használni akarunk
  • Static import: 'import static HtmlColours;' ami után a csomagbeli attribútumok is direkt elérhetőek lesznek, azaz nem kell 'HtmlColours.RED', hanem elég csak a 'RED'

Beágyazott osztályok

Eddig minden osztály top-level volt, azaz a package-én belül legkívül, de persze ezt lehet cifrázni is, méghozzá négyféleképpen:

Inner class

Ez az a helyzet, amikor egy osztályon belül definiálunk egy másikat, de az nem static.
Ekkor a belső osztályra csak a külső példányai tudnak hivatkozni, tehát a belső minden példányához tartozik a külsőnek egy példánya, ami létrehozta.
Ezért semmi akadálya, hogy a belső osztály elérje a külsőnek ezen példányát!
class Genus
{
    String genusName; // pl. 'canis'
    class Species {
        String speciesName; // pl. 'aureus'
        class Subspecies {
            String subspecName; // pl. 'indicus'
            public String toString() {
                return genusName + " " + speciesName + " " + subspecName;
            }
        }
        Subspecies[] subspecies;
    }
    Species[] species;
}
// ...
Genus[] genus;
// ...
System.out.println(genus[4].species[1].subspecies[3].toString());
Ha, tegyük fel, mindhárom class csak simán 'name'-nek hívta volna az attribútumát, az sem lenne baj, csak ekkor a toString() így alakulna:
public String toString() {
    return Genus.name + " " + Species.name + " " + name;
}
Az Inner classok természetesen nem tudnak statikus dolgokat deklarálni, merthogy ők mindig a külső osztály valamely példányához kötődnek, a statikus dolog meg attól független volna.
Mivel az interface-ek eredendően példány-függetlenek, ezért Inner interface nincsen :D.

Static nested class

Ha a belső osztály 'static', akkor értelemszerűen nem tud a külső osztálynak a példányhoz kötődő, tehát nem-static dolgaihoz hozzáférni.
Igazából ez olyan, mintha maga is top-level class volna, csak kényelmi/csoportosítási okból került volna egy másik belsejébe.
Erre még kívülről is lehet hivatkozni 'x = new Külső.StaticBelső(...)' formában.

Local class

Az Inner class unokatesója, csak ezt egy tetszőleges { } blokkon belül definiáljuk és használjuk. Egyébiránt ugyanaz pepitában.

Anonymous class

Amikor csak azért kellene egy új classt definiálni, hogy egyetlen helyen egyetlen példányt létrehozzunk belőle, akkor ez megspórolható:
Thread t = new Thread(new Runnable {
    public void run() {
        // csinál valamit egy külön threaden
    }
} );
t.start();
Itt mi tulajdonképpen a Runnable interface-t valósítottuk meg egy névtelen osztállyal, definiálva annak a run() metódusát, ebből hoztunk létre egy új példányt, amit átadtunk egy Thread konstruktorának, majd elindítottuk ezt a threadet.

Exception handling

A Java hibakezelése nem feltétlenül korlátozódik a hibakód-visszadásra, hanem van lehetőség egy kissé rugalmasabb, bár nagyobb odafigyelést igénylő módszerre is.
Amikor valahol a hívási lánc mélyén valami olyasmi történik, ami a további végrehajtást lehetetlenné teszi (pl. hálózati hiba lépett fel), akkor a hiba helyénél mód van a végrehajtást azonnal megtörni, az egymásba ágyazódó blokkokból azonnal kilépkedni (kb. mint ciklusból a break), egészen addig, amíg valaki fel nincs készülve ennek az eseménynek a kezelésére.
Hogy milyen esemény is történt, illetve annak milyen részletei voltak, azt úgy tudjuk feljuttatni ehhez a kezelőhöz, hogy tulajdonképpen egy objektum-példányt 'dobunk' felfelé neki:
throw new NetworkErrorException("füstöl a drót");
Aki pedig ezt el akarja kapni (tetszőleges szinttel feljebb), az így teheti meg:
try {
    // csinál valamit, amiből jöhet a NetworkErrorException
}
catch (NetworkErrorException e) {
    // lekezeli az eseményt
}
catch (FileNotFoundException e) {
    // lekezel egy másik eseményt is
}
finally {
    // ez még akkor is lefut, ha netán valami egyéb exception jött, amit mi is engedünk tovább
    // kiváló hely a cleanup teendőkre
}
Ha egy metódus dobni (vagy továbbengedni) szeretne egyfajta exceptiont, akkor ezt a metódus deklarációjában jelezni kell:
public void Valami() throws NetworkErrorException, IOException { ...
És még egy Jáva-mágia: kivéve, ha az illető exception a RuntimeException-ből származik, mert azt nem kell külön jelezni...

A primitív típusok wrapperei

Ezzel még adósok vagyunk, bár igazából sehová sem passzol rendesen :D.
A primitív típusokat lehet használni kifejezésekben, és ennyi. Nem tudják megmondani az értékkészletüket, nem tudják magukat Stringgé konvertálni (tényleg nem! az mágia volt!), sem Stringből kielemezni, stb., mert nem objektumok.
Hasonlóképpen nem lehet őket generikus típusokban sem használni, tehát nincs 'List<byte>'.
Ezt feloldandó mindegyik kapott egy wrapper classt, ami mindezeket tudja helyette:
Byte, Integer, Long, Boolean, Character, Float, Double.
Nekik van mindenféle szépségük, konstruktor a primitív típusról ill. Stringről, toString(), static valueOf(String s), stb.
Miért van hát akkor szükség a primitív típusokra?
Mert a Javában nincs operator overloading, azaz objektumokat nem tudunk aritmetikai/logikai kifejezésekben szerepeltetni, hozzájuk pl. '+' operátort definiálni.
Ezért kellett, hogy ezek nyelvi elemek legyenek.
És mi is volt az a Stringesítő mágia?
Amikor azt írjuk, hogy
int i = 42;
System.out.println("i értéke " + i);
akkor valójában az int-ről implicite keletkezik egy Integer, és annak hívódik a toString()-je.
Mondom hogy mágia :D !

Static initializer blokkok

Ha valahol, bárhol lerakunk ilyen blokkokat:
static {
    // bármi kód
}
akkor ezek le fognak futni, amikor az adott class betöltődik, méghozzá fentről lefelé sorrendben és pontosan egyszer.
Ronda mágiákat lehet ezzel csinálni, no pláne az enum-ok inicializálásával átfűzve... Ha nem muszáj, ne tegyük!

A leggyakrabban használatos standard package-ek és osztályok

A teljesség igénye nélkül, csak az említés szintjén:
  • java.lang
    Object, String, Math,
    Byte, Integer, stb.,
  • java.util
    List<T>, Deque<T>, Map<K,V>, Vector<T>, Locale
  • java.text
    Format, SimpleDateFormat
  • java.io
    Stream, Reader, Writer, File
  • javax.swing
    GUI komponensek
Mivel ezek már szigorúan véve nem a nyelv elemei, így ezekről egy külön jegyzetben esik majd szó.



No comments:

Post a Comment