Java SE 8 Documentation
Feladatok

Osztályok származtatása és kiterjesztése

Amikor osztályokat definiálunk, könnyen kerülhetünk olyan helyzetbe, amikor tulajdonképpen már sokadjára ugyanazokat a metódusokat vagy adattagokat írjuk le, csak éppen egy újabb osztályban. Tekintsük például azt, amikor készítünk egy köröket ábrázoló objektumtípust:

// Ez a korábbi implementációnak egy rövidített változata.
class Circle {
    private Point2D center;
    private double radius;
    private double area;
    private double circumference;

    public static Circle make(double x, double y, double radius) {
        return ((radius > 0) ? new Circle(x, cy, radius) : null);
    }

    private Circle(double x, double y, double radius) {
        center = new Point2D();
        center.x = x;
        center.y = y;
        this.radius = radius;
        derive();
    }

    public double getRadius() {
        return radius;
    }

    public void setRadius(double radius) {
        if (radius > 0) {
            this.radius = radius;
            derive();
        }
    }

    private void derive() {
        area          = radius * radius * Math.PI;
        circumference = 2 * radius * Math.PI;
    }

    public double getArea() {
        return area;
    }

    public double getCircumference() {
        return circumference;
    }

    public String toString() {
        return String.format("Circle { center = %s, radius = %.3f }",
          center.show(), radius);
    }
}

Később aztán szeretnénk készíteni egy négyzeteket ábrázoló objektumtípust:

class Square {
    private Point2D center;
    private double side;
    private double area;
    private double circumference;

    public static Square make(double x, double y, double side) {
        return ((side > 0) ? new Square(x, y, side) : null);
    }

    private Square(double x, double y, double side) {
        center = new Point2D();
        center.x = x;
        center.y = y;
        this.side = side;
        derive();
    }

    public double getSide() {
        return side;
    }

    public void setSide(double radius) {
        if (side > 0) {
            this.side = side;
            derive();
        }
    }

    private void derive() {
        area          = side * side;
        circumference = 4 * side;
    }

    public double getArea() {
        return area;
    }

    public double getCircumference() {
        return circumference;
    }

    public String toString() {
        return String.format("Square { center = %s, side = %.3f }",
          center.show(), side);
    }
}

Észrevehetünk némi hasonlóságot az osztályok felépítésében és a metódusok viselkedésében, ezért célszerű lenne ezt kihasználnunk! Ezzel egy könnyebben kezelhető, karbantartható programot kapunk, valamint jobban tudjuk modularizálni a szerkezetét.

Ezért emeljük ki a közös részeket egy közös ősbe, egy ún. ősosztályba. Ebből aztán a másik kettőt származtathatjuk, vagyis az őst megfelelő módon kiterjesztve visszakapjuk a korábbi implementációkat, csak éppen egy rövidebb programban.

Ebben az esetben a közös őst a Shape osztály lesz, amely általánosságban ír le alakzatokat. Ennek megfelelően csak az azokra jellemző általános metódusokat és adattagokat tartalmazza:

class Shape {
    Point2D center;
    protected double area;
    protected double circumference;

    public Shape(double x, double y) {
        center = new Point2D();
        center.x = x;
        center.y = y;
    }

    public double getArea() {
        return area;
    }

    public double getCircumference() {
        return circumference;
    }

    public String toString() {
        return String.format("center = %s", center.show());
    }
}

Utána ennek specializációjaként hozhatjuk létre a korábbi osztályainkat. Ilyenkor az ősosztály metódusait és adattagjait is látjuk, ezért azokat már nem kell újra megadnunk.

class Circle extends /* "is a" */ Shape {
    private double radius;

    public static Circle make(double x, double y, double radius) {
        return ((radius > 0) ? new Circle(x, y, radius) : null);
    }

    private Circle(double x, double y, double radius) {
        super(x, y);
        this.radius = radius;
        derive();
    }

    public double getRadius() {
        return radius;
    }

    public void setRadius(double radius) {
        if (radius > 0) {
            this.radius = radius;
            derive();
        }
    }

    private void derive() {
        super.area          = radius * radius * Math.PI;
        super.circumference = 2 * radius * Math.PI;
    }

    public String toString() {
        return String.format("Circle { %s, radius = %.3f }", super.toString(),
          radius);
    }
}

class Square extends /* "is a" */ Shape {
    private double side;

    public static Square make(double x, double y, double side) {
        return ((side > 0) ? new Square(x, y, side) : null);
    }

    private Square(double x, double y, double side) {
        super(x, y);
        this.side = side;
        derive();
    }

    public double getSide() {
        return side;
    }

    public void setSide(double side) {
        if (side > 0) {
            this.side = side;
            derive();
        }
    }

    private void derive() {
        super.area          = side * side;
        super.circumference = 4 * side;
    }

    public String toString() {
        return String.format("Square { %s, side = %.3f }", super.toString(),
          side);
    }
}

Az eredményül keletkező kódban azonban megfigyelhetjük, hogy a "tömörítés" során előkerült két új szimbólum is: a super és protected.

A super tulajdonképpen a this testvére, csak ezúttal nem az aktuális objektumra, vagy annak konstruktorára fog hivatkozni, hanem az ősre. A származtatás miatt ugyanis az objektumokhoz mindig létrejön a neki megfelelő ősobjektum, amelyet szükség esetén a super referencián keresztül tudunk elérni. Itt most arra használtuk, hogy meg tudjuk hívni az őshöz tartozó konstruktort, valamint annak a toString() metódusát. A Circle és a Square konstruktoraiban a super() hívás lényegében kötelező is, mivel másképpen a rendszer nem fogja tudja inicializálni az őshöz tartozó adattagokat (leginkább a center értéket). A super() hívással kapcsolatban még arra kell figyelnünk, hogy mindig a konstruktor első utasítása legyen. Ha az ős konstruktorának nincsenek paraméterei, akkor el is hagyhatjuk, mert ezt a fordító képes magától is beilleszteni. Ha azonban nem ez a helyzet, akkor mindenképpen ki kell írnunk.

A másik eset, amikor a super még előkerült, az a referencia. A fenti kódrészleteben a super.toString() hívást használtuk, ami arra utal, hogy nem az aktuális típushoz tartozó toString() példányt akarjuk meghívni, hanem az ős eredeti metódusát, amelyet felüldefiniáltunk. Ennek segítségével tudjuk elérni, hogy Shape a ponthoz tartozó részszöveg előállítsa, és azt mi csak felhasználjuk a leszármazattokban (tehát nem kell már újra megírni).

Amit még megfigyelhetünk, hogy a korábbi private módosító helyett az osztott adattagok esetén a protected módosítót használtuk. Ennek az a célja, hogy elrejtse kívülről az adattagokat, viszont lehetővé tegye ezek elérhetőségét a leszármazatottakban. Ha ugyanis private maradna, akkor már a származtatott osztályokból sem tudnánk elérni ezeket. Erre most azért volt szükség, mert a Circle és Square esetén közvetlenül módosítjuk az area és circumference adattagok értékeit. De ha például külön írtunk volna erre az ősben módosítófüggvényeket, akkor maradhattak volna private értékek.

A java.lang.Object mint közös ős

A Java nyelvben megtalálható objektumoknak van egy közös őse, ez pedig a java.lang.Object. Mivel bármelyik objektumtípus implicit módon ebből az osztályból származik (tehát ha nem írjuk ki az extends után ősét, akkor az automatikusan ez az osztály lesz), vagyis örökli annak a műveleteit. Ilyen műveletek például az equals() és a toString(). Viszont ezek, tekintettel arra, hogy a java.lang.Object a Shape osztályunkhoz hasonlóan egy általánosítás, egy elég általános viselkedést társítanak az objektumhoz. Az equals() lényegében a referenciákat hasonlítja össze (mivel más információ nem áll rendelkezésre), a toString() pedig az objektum tárbeli címe és a konkrét típusa alapján állít elő egy szöveges alakot.

class Dummy /* extends Object */ {}

A Dummy osztályunk tehát mivel a java.lang.Object osztályt terjeszti ki, eleve rendelkezni fog annak metódusaival. Így lesz equals() és toString() is hozzá.

Dummy d1 = new Dummy();
Dummy d2 = new Dummy();
System.out.println(String.format("d1 = %s, d2 = %s", d1.toString(), d2.toString()));
System.out.println(d1.equals(d2));

Láthattuk viszont az eddigiekben, hogy a toString() metódust szükség szerint felül tudtuk definiálni a leszármazattokban. (A felüldefiniálásról még később részletesebb szó lesz.)

Öröklődés helyett kompozíció

A származtatásnak lesz egy azonban mellékhatása is, ugyanis a származtatás során az ősként funkcionáló osztály a leszármazottjainak általánosítása lesz, vagyis (az ún. Liskov-féle helyettesítési elv mentén) bárhova be tudjuk írni a leszármazottat, ahova az őst be tudtuk korábban írni (hiszen ismeri ugyanazokat a műveleteket).

Ezért lehet akár ilyet is írni:

Shape[] shapes = new Shape[] { Circle.make(0,0,1), Square.make(0,0,1) };

for (Shape s : shapes) {
    System.out.println(s.toString());
}

Ezért amikor különböző objektumtípusokat akarunk összekapcsolni a programjainkban, akkor nem biztos, hogy valójában az öröklődést kell használunk. Sőt, legtöbb esetben az öröklődést egyáltalán nem is kell használnunk.

A kompozíció az objektumtípusok közti kapcsolat (asszociáció) egyik fajtája. Ebben az esetben arról van szó, hogy a két osztály között nem öröklődési kapcsolat van, hanem csupán az egyik osztály felhasználja a másik osztály a definíciójához, annak szerves része lesz, anélkül nem létezhet.

Példaként tekintsük a Person típus definícióját:

class Person {
    private String  name;  /* String  "is part of" Person */
    private Integer age;   /* Integer "is part of" Person */

    public static Person make(String name, Integer age) {
        return ((age > 0 && !name.isEmpty()) ? new Person(name, age) : null);
    }

    private Person(String name, Integer age) {
        this.name = new String(name);
        this.age  = new Integer(age);
    }

    public String getName() {
        return new String(name);
    }

    public Integer getAge() {
        return new Integer(age);
    }

    public String toString() {
        return String.format("Person { name = %s, age = %d }", name, age);
    }
}

Itt a Person objektum felhasználta a saját definíciójához a String és Integer objektumokat. Ezek a komponensek mindig egy Person értékhez tartoznak, önmagukban használjuk ezeket.

Person jesus = Person.make("Jesus Christ", 32);

Tulajdonképpen az öröklődés kompozícióval ki is váltható. A Circle osztályunkat például meg tudjuk úgy is írni, hogy a Shape, mint alkatrész szerepel benne.

class Circle {
    private Shape shape;
    private double radius;

    public static Circle make(double x, double y, double radius) {
        return ((radius > 0) ? new Circle(x, y, radius) : null);
    }

    private Circle(double x, double y, double radius) {
        shape = new Shape(x, y);
        this.radius = radius;
        derive();
    }

    public double getRadius() {
        return radius;
    }

    public void setRadius(double radius) {
        if (radius > 0) {
            this.radius = radius;
            derive();
        }
    }

    private void derive() {
        shape.area          = radius * radius * Math.PI;
        shape.circumference = 2 * radius * Math.PI;
    }

    public String toString() {
        return String.format("Circle { %s, radius = %.3f }",
          shape.toString(), radius);
    }
}

Ekkor viszont a Circle nem használható Shape értékként.

Öröklődés helyett agregáció

Természetesen nem kötelező, hogy a kompozícióban felhasznált objektum létezése mindig függjön a befoglaló objektum létezésétől. Ezért alkalmazható az agregáció is, mint az objektumtípusok közti másik lehetséges kapcsolat.

Ennek szemléltetéséhez maradjunk a Person definíciójánál, és egészítsük ki egy újabb adattaggal, amely egy személy barátait tárolja el!

class Person {
    private String            name;    /* String  "is part of" Person */
    private Integer           age;     /* Integer "is part of" Person */
    private ArrayList<Person> friends; /* Person  "has" Persons */

    public static Person make(String name, Integer age) {
        return ((age > 0 && !name.isEmpty()) ? new Person(name, age) : null);
    }

    private Person(String name, Integer age) {
        this.name    = new String(name);
        this.age     = new Integer(age);
        this.friends = new ArrayList<Person>();
    }

    public String getName() {
        return new String(name);
    }

    public Integer getAge() {
        return new Integer(age);
    }

    public void addFriend(Person friend) {
        friends.add(friend);
    }

    public String toString() {
        return String.format("Person { name = %s, age = %d, friends = %s }",
          name, age, friends.toString());
    }
}

Látható, hogy a barátok a konkrét Person típusú érték nélkül is léteznek. De ha szeretnénk, akkor két Person értéket így össze tudunk kapcsolni.

Person stan = Person.make("Stanley Marsh", 11);
jesus.addFriend(stan);
System.out.println(stan.toString());
System.out.println(jesus.toString());

Feladatok

  • Valósítsunk meg egy utils.List típust, amely láncolt lista adatszerkezetben képes dinamikus növekvő méretű, java.lang.Object típusú értékeket tároló (agregáció) tömböket ábrázolni! A láncolás azt jelenti, hogy az új elemeket nem konkrétan egy tömb típusú értékben tároljuk el, hanem egymásra hivatkozó (rekurzív) listák sorozataként, amely a rákövetkező lista mellett mindig tárolja az aktuális elején található elemet is. Ha egy listának már rákövetkező listája, akkor ott a null referencia szerepeljen!

    Ennek az ábrázolási módnak a felhasználásával implementáljuk erre a típusra az alábbi műveleteket:

    • egy konstruktor, amely egy elemet kapva paraméterül, létrehozza az azt az elemet tartalmazó, egyelemű listát,

    • add(): egy elem beillesztése a láncolt lista elejére,

    • concat(): a paraméterül kapott másik láncolt lista az aktuális lista után fűzése,

    • getFirst(): a listában található első elem lekérdezése,

    • getRest(): a lista fennmaradó részének (mint List) visszaadása, vagyis a lista az első elem nélkül,

    • length(): a lista hosszának (rekurzív) lekérdezése,

    • remove(): az első elem törlése,

    • toArray(): a listában tárolt értékek egyetlen tömbként történő visszaadása (rekurzívan),

    • toString(): (rekurzívan) szöveggé alakítás.

  • Készítsünk egy olyan programot, amely a szabványos bemenetről be tudja olvasni különböző fajta alakzatok adatait, majd azokat eltárolni! Mivel nem tudjuk előre pontosan, hogy mennyi alakzattal kapcsolatban akarunk majd adatokat rögzíteni, ezért célszerű azokat egy java.util.ArrayList típusú objektumba tenni.

    A beolvasást addig folytatjuk, amíg a bemenet tart ("EOF, az "End of File" jelzést nem kapunk), vagy amíg nem kapunk egy "quit" üzenetet.

    A másik ilyen parancsunk legyen az "add", amely után zárójelek között megadjuk, hogy milyen alakzatot, milyen paraméterekkel akarunk felvenni. Az alakzataink rendre:

    • "rectangle": Rectangle típusú objektum, vagyis egy téglalap létrehozása a bal felső pont x és y koordinátájával, valamint az x és y irányú oldalhosszok megadásával,

    • "square": Square típusú objektum, vagyis négyzet lébrehozása a bal felső pont x és y koordinátáival, valamint az oldalhossz megadásával,

    • "ellipse": Ellipse típusú objektum, vagyis ellipszis létrehozása a középpont x és y koordinátáival, valamint a nagytengely és a kistengely megadásával,

    • "circle": Circle típusú objektum, vagyis kör létrehozása a középpont x és y koordinátánaik, valamint a sugár megadásával.

    Miután befejeztük a beolvasást, a szabványos kimeneten jelenítsük meg az összes beolvasott értéket (a saját toString() metódusán keresztül)!

  • Készítsünk egy arrays.generic.MArray nevű osztály, amely tetszőleges dimenziójú tömböt képes ábrázolni egyetlen egydimenziós tömb segítségével! A tömbben java.lang.Object értékeket tároljunk el (agregáció), így ennek köszönhetően lényegében bármilyen típusúak lehetnek. A műveletei legyenek a következőek:

    • konstruktor, amellyel meg lehet adni, hogy mennyi dimenziónban és azokban a tömbünk milyen kiterjedésű legyen,

    • set(), amellyel be tudjuk állítani egy elem értéket az adott dimenziók szerinti pozícióban, nyilván csak akkor, ha a dimenziók száma, és azon belül a indexek megfelelőek,

    • get(), amellyel le tudjuk kérdezni egy elem értékét, amennyiben a megadott pozíció dimenziója és azon belül az indexek megfelelőek, ellenkező esetben null referenciát adjunk vissza,

    • getDimensions(), amely visszaadja, hogy mekkorák a tömb egyes dimenziói.

  • Az arrays.generic.MArray felhasználásával (kompozíció) valósítsunk meg egy arrays.DoubleMatrix osztályt, amely Double típussal valós számokat tárol egy mátrixban! Az osztálynak legyenek a következő műveletei:

    • konstruktor, amely megadja, hogy az egyes dimenziókban mekkora a mátrix kiterjedése,

    • set(), amellyel be tudunk állítani értéket az adott pozícióban,

    • get(), amellyel le tudjuk kérdezni az adott pozíción található értéket,

    Érvénytelen indexek (vagyis amikor olyan értéket adunk meg, amelyek már nem a mátrix egy elemét címzik) esetén ne változtassuk meg a mátrixot! A get() esetén pedig Double (csomagoló) objektumokat adjunk vissza, így a null referenciával tudjuk jelezni, ha érvénytelen pozícióról nem kérdezhető le elem.

    Fontos, hogy a felhasználó számára ne legyen látható, hogy az arrays.generic.MArray segítségével valósítottuk meg ezt a típust!

Linkek

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