Ugrás a tartalomhoz

OOP

A Wikiszótárból, a nyitott szótárból

Rövidítés

OOP

  1. (informatika) object-oriented programming

Az objektum-orientált programozás (OOP) alapelvei közé tartozik az osztály, objektum, adatrejtés (encapsulation), absztrakció, polimorfizmus, öröklődés, dinamikus kötés és üzenetküldés. Ezek a fogalmak együtt biztosítják, hogy a programok jól strukturáltak, könnyebben karbantarthatók és bővíthetők legyenek. Az alábbiakban részletesen bemutatjuk ezeket a fogalmakat rövid magyarázatokkal és C++ kódpéldákkal.

1. Osztály és Objektum (Class and Object)

Osztály (class): Az osztály egy felhasználó által definiált adatstruktúra, amely adattagokat (állapot) és az azokat kezelő metódusokat (függvényeket, viselkedés) tartalmaz. Az osztály afféle tervrajz vagy sablon, amely alapján konkrét példányokat hozhatunk létre a programban ([1]).

Objektum (object): Az objektum egy osztály konkrét példánya a futásidő során – olyan entitás, amely tárolja az osztályban definiált adatok konkrét értékeit, és képes végrehajtani az ott meghatározott műveleteket ([2]). Minden objektumnak van állapota (az adattagok aktuális értékei) és viselkedése (a tagfüggvények által definiált műveletek). Egyszerűen fogalmazva: az osztály leírja, milyen adatokkal és műveletekkel rendelkezik egy bizonyos típus, míg az objektum ennek a leírásnak a megvalósult példája a programban.

Példa – Egyszerű osztály és objektum: Az alábbi C++ példa definiál egy Szemely osztályt, amelynek van egy adattagja (nev) és egy metódusa (koszont()), majd létrehoz egy objektumot ebből az osztályból és meghívja annak metódusát.

#include <iostream>
#include <string>
using namespace std;

class Szemely {
public:
    string nev;                   // adattag (nyilvános, public)
    void koszont() {              // metódus
        cout << "Szia, " << nev << "!" << endl;
    }
};

int main() {
    Szemely ember;                // objektum létrehozása (példányosítás)
    ember.nev = "Anna";           // adattag értékének beállítása
    ember.koszont();              // metódus meghívása -> Kiírja: "Szia, Anna!"
    return 0;
}

A fenti példában a Szemely osztály egy sablon, ami megmondja, hogy egy személynek van neve és tud köszönni. Az ember nevű objektum a Szemely osztály egy példánya, amelynek nev adattagját beállítjuk “Anna”-ra, majd meghívjuk a koszont() metódusát, ami üdvözli Annát. Az osztály és objektum viszonya tehát hasonló a tervrajz és a konkrét tárgy viszonyához.

2. Adatrejtés (Encapsulation)

Az encapsulation (adatrejtés) lényege, hogy az objektum elrejti belső adatait és azok kezelésének módját a külvilág elől ([3]). Ezt úgy érjük el, hogy az osztály adattagjait gyakran privát (private) láthatóságúra állítjuk, így kívülről nem érhetők el közvetlenül. Az objektum állapotát és belső működését csak a nyilvános interfészen (public metódusokon) keresztül lehet megváltoztatni vagy lekérdezni. Ez az információ elrejtése biztosítja, hogy a belső adatok mindig érvényes állapotban maradjanak, és az objektum használói ne kerülhessék meg a beépített logikát.

A gyakorlatban az adatrejtést a privát adattagok és a hozzájuk tartozó getter/setter függvények alkalmazásával valósítjuk meg. A getter függvények visszaadják egy privát adattag értékét, míg a setter függvények ellenőrzött módon változtatják meg azt. Az alábbi példában egy BankAccount osztály privát adattaggal (balance) és nyilvános műveletekkel kezeli a számla egyenlegét.

#include <iostream>
using namespace std;

class BankAccount {
private:
    double balance;                     // privát adattag (belső állapot)
public:
    BankAccount(double initial) : balance(initial) {}  // konstruktor

    // Getter függvény az egyenleg lekérdezésére
    double getBalance() {
        return balance;
    }

    // Setter-szerű műveletek az egyenleg módosítására
    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
    void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }
};

int main() {
    BankAccount account(1000.0);       
    // account.balance = 500; // Fordítási hiba: a balance privát, kívülről nem elérhető

    account.deposit(200.0);            // Helyes mód: nyilvános metódussal növeljük az egyenleget
    account.withdraw(150.0);           // Nyilvános metódussal csökkentjük az egyenleget
    cout << "Egyenleg: " << account.getBalance() << endl;
    // Kimenet: Egyenleg: 1050
    return 0;
}

A fenti példában a balance adattag privát, így közvetlenül nem módosítható a main függvényből. Csak a deposit és withdraw metódusokon keresztül lehet változtatni az egyenleget, amelyek ellenőrzik a bemenetet (pl. nem lehet negatív összeget befizetni vagy többet kivenni a meglévő egyenlegnél). Ez biztosítja az objektum érvényes állapotát és elrejti a belső működés részleteit. Az adatrejtés így hozzájárul a kód biztonságához és karbantarthatóságához, mivel a belső reprezentációt később szabadon megváltoztathatjuk anélkül, hogy a külső kódot módosítani kellene, amennyiben az továbbra is a nyilvános interfészt használja.

3. Absztrakció (Abstraction)

Az absztrakció azt jelenti, hogy egy rendszer működésének bonyolult részleteit elrejtjük, és csak a lényeges tulajdonságokat emeljük ki. Másképpen fogalmazva, az absztrakció során a felhasználó számára fontos műveletekre és adatokra koncentrálunk, a belső megvalósítást pedig elrejtjük egy egyszerűbb interfész mögé ([4]). Ennek eredményeképpen a programozó az objektummal egy magasabb szintű absztrakcióként dolgozhat, anélkül hogy ismerné a belső részleteket. Az absztrakció segít csökkenteni a komplexitást és növeli a kód újrafelhasználhatóságát, mivel különválasztja a mit csinál (interfész) és a hogyan csinálja (implementáció) kérdését.

A C++ nyelvben az absztrakció egyik eszköze az absztrakt osztály. Az absztrakt osztály olyan osztály, amelyben legalább egy metódus csak deklarálva van, de nincs implementációja – ezeket nevezzük pure virtual (tiszta virtuális) függvényeknek. Az ilyen osztály nem példányosítható, hiszen nem teljes a megvalósítása ([5]). Az absztrakt osztályok inkább egy közös felületet határoznak meg a belőlük származó osztályok számára, és az absztrakt metódusok konkrét működését már a leszármazott osztályok valósítják meg. Ezáltal elérjük, hogy a program a konkrét részletektől elvonatkoztatva, az absztrakt ősosztály interfészén keresztül dolgozzon az objektumokkal.

Példa – Absztrakt osztály és leszármazottai: Az alábbi kód egy absztrakt Shape (alakzat) osztályt definiál, amelynek van egy absztrakt művelete (draw()), valamint két konkrét osztályt (Circle és Rectangle), amelyek öröklik a Shape-et és megvalósítják a draw() metódust. A főfüggvényben látható, hogy nem hozhatunk létre Shape objektumot, viszont használhatunk Shape* mutatót a különböző alakzatok kezelésére egységes módon.

#include <iostream>
using namespace std;

class Shape {
public:
    // Pure virtual függvény - absztrakt művelet
    virtual void draw() = 0;
};

class Circle : public Shape {
public:
    void draw() override {
        cout << "Egy kört rajzolunk." << endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        cout << "Egy téglalapot rajzolunk." << endl;
    }
};

int main() {
    // Shape base;        // Fordítási hiba: absztrakt osztályból nem lehet példányt létrehozni
    Circle c;
    Rectangle r;
    Shape* shapePtr = &c;   // Az shapePtr mutató kezelhető általános Shape-ként
    shapePtr->draw();       // Kimenet: "Egy kört rajzolunk."
    shapePtr = &r;
    shapePtr->draw();       // Kimenet: "Egy téglalapot rajzolunk."
    return 0;
}

A fenti példában a Shape absztrakt osztály definiál egy draw() nevű műveletet, de nem ad hozzá implementációt (ezért = 0). A Circle és Rectangle osztályok öröklik a Shape-et és felüldefiniálják (override) a draw() metódust saját megvalósítással. Az absztrakció előnye, hogy a főprogramban a shapePtr mutató segítségével nem kell tudnunk, épp egy kör vagy egy téglalap objektummal van dolgunk – elég annyit tudni, hogy létezik egy draw() művelet, amit meghívhatunk. A konkrét rajzolás logikája el van rejtve az absztrakt Shape interfész mögött, és a program futásidejűen a megfelelő konkrét metódust hajtja végre. Az absztrakció így egyszerűsíti a kódot és lehetővé teszi a különböző implementációk egységes kezelését.

4. Polimorfizmus (Polymorphism)

A polimorfizmus jelentése szó szerint “sokalakúság”, és arra utal, hogy ugyanazon művelet különböző formákban hajtható végre, attól függően, hogy milyen objektumon alkalmazzuk. OOP-ben ez tipikusan azt jelenti, hogy egy ősosztály típusú változó vagy mutató többféle, az ősosztályból származó objektumot is képes kezelni, és a megfelelő műveletet hajtja végre az aktuális objektum típusa szerint ([6]). Másképpen: egy közös interfészen keresztül különböző konkrét megvalósításokat használhatunk.

A polimorfizmusnak két fő típusa van: statikus (korai kötésű) és dinamikus (késői kötésű) polimorfizmus ([7]).

  • Statikus polimorfizmus: Fordítási időben dől el, hogy melyik függvény hajtódik végre. Erre példa a függvény túlterhelés (function overloading) vagy a függvénysablonok használata – a függvényhívás paramétereinek típusa alapján a fordító választja ki a megfelelő függvényváltozatot ([8]). Statikus polimorfizmus esetén nincs futásidejű költség, cserébe nem olyan rugalmas, mint a dinamikus.
  • Dinamikus polimorfizmus: Futásidőben dől el, hogy az adott hívás melyik függvény-implementációt fogja használni. Ezt tipikusan virtuális függvények felülírásával (method overriding) érik el az öröklődő osztályok. Ha egy ősosztály típusú mutatón keresztül hívunk meg egy virtuális metódust, akkor a program futás közben határozza meg, hogy a mutató által mutatott konkrét objektum osztályának felüldefiniált függvényét hívja meg ([9]). Ez nagy rugalmasságot ad, hiszen a kód képes alkalmazkodni az objektum valódi típusához futásidőben (például egy Animal* mutató hívhatja egyaránt kutya vagy macska specifikus hangját), ugyanakkor a dinamikus kötésnek van némi futásidőbeli költsége.

Példa – Statikus polimorfizmus (függvény túlterhelés): Az alábbi példában két azonos nevű osszead függvény van definiálva különböző paramétertípusokkal. A fordító a híváskor automatikusan eldönti, melyiket kell hívni a paraméterek típusa alapján.

#include <iostream>
#include <string>
using namespace std;

int osszead(int a, int b) {
    return a + b;
}

string osszead(string a, string b) {
    return a + b;
}

int main() {
    cout << osszead(5, 3) << endl;                 // Meghívja az int változatot, kimenet: 8
    cout << osszead(string("alma"), string("fa")) << endl;
    // Meghívja a string változatot, kimenet: almafa
    return 0;
}

A fenti túlterheléses példában az osszead függvény két változata a bemenet típusától függően mást csinál (egész számot ad össze vagy sztringet fűz össze). A megfelelő függvény kiválasztása fordítási időben megtörténik a paraméterek típusa alapján. Ez statikus polimorfizmus, mivel nincs szükség futásidejű döntésre.

Példa – Dinamikus polimorfizmus (virtuális függvények): A következő példában van egy Allat ősosztály, amely definiál egy virtuális hang() metódust. Két leszármazott osztály (Kutya és Macska) felülírja ezt a metódust a saját hangjával. A main függvényben egy Allat* mutató segítségével hívjuk meg a hang() metódust különböző konkrét állatokon. A futás során dől el, hogy éppen a Kutya vagy a Macska implementációja fut le.

#include <iostream>
using namespace std;

class Allat {
public:
    virtual void hang() {                   // virtuális függvény (alapértelmezett viselkedés)
        cout << "Az állat hangot ad ki." << endl;
    }
};

class Kutya : public Allat {
public:
    void hang() override {                  // virtuális függvény felülírása
        cout << "A kutya ugat: vau!" << endl;
    }
};

class Macska : public Allat {
public:
    void hang() override {                  // virtuális függvény felülírása
        cout << "A macska nyávog: miau!" << endl;
    }
};

int main() {
    Kutya kutya;
    Macska macska;
    Allat* a1 = &kutya;
    Allat* a2 = &macska;
    a1->hang();   // Kimenet: "A kutya ugat: vau!"
    a2->hang();   // Kimenet: "A macska nyávog: miau!"
    return 0;
}

Látható, hogy az Allat* típusú a1 mutató először egy Kutya objektumra mutat, majd egy Macska objektumra. Amikor meghívjuk az a1->hang() metódust, a program dinamikusan kötve a megfelelő felüldefiniált függvényt hívja: először a kutya ugató hangját, majd a macska nyávogását. Ez a polimorfizmus ereje – a kódot az absztrakt ősosztályra (Allat) írtuk meg, de futás közben a megfelelő konkrét osztály metódusa fut le. Így új állatfajt hozzáadhatunk (új leszármazott osztály definiálásával) anélkül, hogy az Allat-ra írt kódot módosítani kellene, mert a polimorfikus viselkedés automatikusan kiterjed az új osztályra is.

5. Öröklődés (Inheritance)

Az öröklődés egy olyan kapcsolat két osztály között, ahol az egyik osztály (az ős vagy bázisosztály) átadja tulajdonságait egy másik osztálynak (a leszármazott vagy gyerekosztály). A leszármazott osztály örökli az ősosztály minden adattagját és metódusát (láthatósági szinttől függően), így újra felhasználhatjuk az ősosztályban megírt kódot ([10]). Emellett a gyerekosztály kibővítheti vagy módosíthatja az örökölt viselkedést (új adattagokat és metódusokat adhat hozzá, illetve felülírhat virtuális metódusokat). Az öröklődés modellezi az “is-a” (egyfajta) kapcsolatot: például egy Kutya egy fajta Allat, ezért az Allat általános tulajdonságai érvényesek a Kutya esetén is.

Az öröklődés fő előnyei:
- Kódújrafelhasználás: Az ősosztályban írt kódot nem kell megismételni a leszármazottban, elég örökölni.
- Strukturáltság: Természetes hierarchiát hoz létre a programban, ami áttekinthetőbbé teszi a kódot.
- Polimorfizmus alapja: Lehetővé teszi, hogy a leszármazottak kezelhetők legyenek ősosztályként (altípus-polimorfizmus).

Példa – Egyszerű öröklődés: Az alábbi példa egy Allat bázisosztályt és egy Kutya leszármazottat mutat be. Az Allat osztálynak van egy adattagja (nev) és egy metódusa (eszik()), amelyeket a Kutya örököl. A Kutya ezen felül saját metódussal (ugat()) rendelkezik. A főprogramban létrehozunk egy Kutya objektumot, beállítjuk a nevét (amit az Allat-tól örökölt), és meghívjuk mind a örökölt metódust, mind a saját metódusát.

#include <iostream>
#include <string>
using namespace std;

class Allat {
public:
    string nev;
    void eszik() {
        cout << nev << " eszik." << endl;
    }
};

class Kutya : public Allat {   // Kutya örökli az Allat publikus tagjait
public:
    void ugat() {
        cout << nev << " ugat: vau!" << endl;
    }
};

int main() {
    Kutya kutya;
    kutya.nev = "Bodri";      // 'nev' adattag az Allat-tól örökölve
    kutya.eszik();            // Meghívja az örökölt Allat::eszik() metódust -> "Bodri eszik."
    kutya.ugat();             // Meghívja a saját Kutya::ugat() metódust -> "Bodri ugat: vau!"
    return 0;
}

Az példában a Kutya osztály örökli az Allat osztályt, ezért a Kutya objektum úgy viselkedik, mint egy Allat is: van neve és tud enni (ezeket nem kellett újra megírni), emellett hozzáadtuk, hogy tud ugatni is. Az öröklődés segítségével a már megírt kódot újra felhasználtuk, és egy specializáltabb osztályt hoztunk létre. Ha később az Allat osztályt bővítjük (például adunk egy mozog() metódust), az automatikusan elérhető lesz a Kutya osztályban is, öröklődés révén. Az öröklődést kihasználva több szinten is szervezhetjük a programot (pl. Allat -> Emlős -> Kutya), ezzel hierarchiát építve fel.

6. Dinamikus kötés (Dynamic Binding)

A dinamikus kötés azt jelenti, hogy egy metódushívás konkrét végrehajtását a program futásidejében (dinamikusan) döntjük el, nem pedig fordítási időben. C++-ban a dinamikus kötés tipikusan a virtuális függvényekhez kapcsolódik: ha egy ősosztálybeli metódust virtuálisként jelölünk, és a leszármazott osztály felülírja ezt, akkor az ősosztály típusú mutatón keresztül meghívott függvény futás közben fog a megfelelő leszármazottbeli implementációra kötődni ([11]). Más szavakkal, a program futásidőben határozza meg, melyik osztály metódusát kell hívni az objektum valódi típusa alapján ([12]). (Ezzel szemben a statikus vagy korai kötés esetén – ami az alapértelmezett C++-ban – a hívás az objektum statikus típusához kötődik fordítási időben.)

A dinamikus kötés fontossága abban rejlik, hogy polimorf viselkedést tesz lehetővé. Enélkül (azaz ha minden metódus hívása statikusan kötődne) nem működne a fent említett dinamikus polimorfizmus – mindig az ősosztály implementációja futna, még akkor is, ha az objektum valójában egy leszármazott példány. A virtual kulcsszóval jelezzük, hogy adott függvényt késői kötés szerint kezeljen a nyelv (C++ ezt virtuális függvény táblákkal, vtable segítségével valósítja meg a háttérben).

Példa – Dinamikus vs. statikus kötés: Az alábbi példában egy Base osztály és egy Derived osztály szerepel. A Base::f() virtuális, a Derived felülírja ezt. A kódban egy Base* mutató segítségével hívjuk a függvényt. Megfigyelhető, hogy ha a mutató egy Derived objektumra mutat, akkor a hívás a felüldefiniált, Derived osztálybeli függvényt indítja el (dinamikus kötés). Ha a virtuális kulcsszót eltávolítanánk, a második hívás is a Base::f()-et hívná (statikus kötés).

#include <iostream>
using namespace std;

class Base {
public:
    virtual void f() {
        cout << "Base f()" << endl;
    }
};

class Derived : public Base {
public:
    void f() override {
        cout << "Derived f()" << endl;
    }
};

int main() {
    Base base;
    Derived derived;
    Base* ptr = &base;
    ptr->f();    // Kimenet: "Base f()"  (ptr egy Base objektumra mutat)
    ptr = &derived;
    ptr->f();    // Kimenet: "Derived f()" (ptr most egy Derived objektumra mutat, virtuális kötés)
    return 0;
}

A fenti kimenet azt mutatja, hogy a második hívásnál a ptr->f() már a Derived osztály implementációját hívja, habár ptr típusa Base. Ez a dinamikus kötés eredménye. Ha a Base::f() nem lett volna virtuális, akkor is a Base változat futna le, mivel a fordító a pointer statikus típusát (Base) használná a kötéshez. A dinamikus kötés tehát lehetővé teszi a program számára, hogy a megfelelő függvényt hívja meg az objektum tényleges típusának megfelelően futásidőben. Ez elengedhetetlen a valódi sokalakúsághoz és az OOP rugalmasságához.

7. Üzenetküldés (Message Passing)

Az üzenetküldés az objektumok közötti kommunikációt jelenti. Az objektumok úgy lépnek kapcsolatba egymással, hogy üzeneteket küldenek – ami a gyakorlatban azt takarja, hogy egyik objektum meghívja a másik valamelyik metódusát ([13]). Amikor egy objektum egy másiknak üzenetet küld, az valójában egy kérés, hogy a címzett objektum hajtson végre egy bizonyos műveletet. Az üzenetküldés fogalma tehát azonosítható a metódus-hívásokkal: a küldő objektum hív egy metódust a fogadó objektumon, ezzel “üzen” neki. A fogadó erre a saját állapotával és viselkedésével reagál (végrehajtja a kért műveletet). Ez a mechanizmus biztosítja, hogy az objektumok laza kapcsolatban legyenek: a küldőnek elég annyit tudnia, hogy a másik objektum képes a kért művelet végrehajtására (azaz ismeri az interfészét), de nem kell ismernie a belső működését.

Példa – Objektumok közötti üzenetküldés: Képzeljünk el egy egyszerű szituációt, ahol van egy Light (lámpa) objektum, amit egy Switch (kapcsoló) objektum vezérel. A kapcsoló “üzenetet küld” a lámpának, hogy kapcsolja fel magát. Az alábbi kód ezt valósítja meg: a Switch::press() metódus meghívja a Light::turnOn() metódust – ez tekinthető üzenetküldésnek az objektumok között.

#include <iostream>
using namespace std;

class Light {
public:
    void turnOn() {
        cout << "A l\u00e1mpa felkapcsol\u00f3dik." << endl;
    }
};

class Switch {
public:
    void press(Light& lamp) {
        cout << "Kapcsol\u00f3 megnyomva. \u00dczenet k\u00fcld\u00e9se a l\u00e1mp\u00e1nak..." << endl;
        lamp.turnOn();
    }
};

int main() {
    Light lamp;
    Switch sw;
    sw.press(lamp);  // A Switch objektum "üzenetet küld" a Light objektumnak a turnOn metódus meghívásával
    return 0;
}

Kimenet:

Kapcsol\u00f3 megnyomva. \u00dczenet k\u00fcld\u00e9se a l\u00e1mp\u00e1nak...
A l\u00e1mpa felkapcsol\u00f3dik.

Ebben a példában a Switch::press() függvény hívása egy üzenetküldési folyamatot reprezentál: a sw objektum utasítja (press) a lamp objektumot, hogy hajtsa végre a turnOn() műveletet. A Switch nem végzi el a lámpa felkapcsolását maga – ezt a feladatot átadja a Light objektumnak (mintegy “üzenszól” neki). Az objektumok közötti üzenetküldés így teszi lehetővé, hogy a programban lévő különálló objektumok együttműködjenek és komplex viselkedést valósítsanak meg azáltal, hogy metódusok meghívásával kommunikálnak egymással.

Összefoglalva: Az objektum-orientált programozás fenti alapfogalmai együttműködve teszik lehetővé, hogy a programokat objektumok laza halmazaként szervezzük, amelyek osztályok alapján jönnek létre, belső adataikat elrejtik és kontrollált módon teszik elérhetővé (encapsulation), a komplexitást absztrakcióval kezelik, különböző konkrét megvalósítások formájában polimorf módon viselkednek, örökléssel szervezhetők hierarchiába és újrahasznosítják a kódot, a megfelelő függvényeket dinamikus kötés révén futásidőben hívják meg, és üzenetküldéssel működnek együtt. Ezen elvek elsajátítása és alkalmazása kulcsfontosságú a jól strukturált, rugalmas és könnyen karbantartható kód írásához az OOP paradigmában.

  • OOP - Szótár.net (en-hu)
  • OOP - Sztaki (en-hu)
  • OOP - Merriam–Webster
  • OOP - Cambridge
  • OOP - WordNet
  • OOP - Яндекс (en-ru)
  • OOP - Google (en-hu)
  • OOP - Wikidata
  • OOP - Wikipédia (angol)