Objektumok élettartama, okos pointerek

Czirkos Zoltán · 2022.10.11.

Funarg problémák kezelése C++ kódban. RAII és okos pointerek írása.

A laborokhoz

A laborok mellé minden héten lesz kiírva egy beadandó az admin portálon. Ide óra végén töltsd fel a forráskódokat (*.cpp, *.h)! A feladatokat ezért külön projektben oldd majd meg, ne írd felül a megoldásokat.

Labor otthoni munkában

A labor teljesítéséhez mind az első három feladatot meg kell oldani.

1. A Hérón-féle gyökvonás

Adott az alábbi, Hérón-féle módszerrel gyökvonást végző algoritmus JavaScript kódja. (A módszer lényege: addig finomítjuk a tippelt gyököt, amíg elég jó nem lesz. A finomítás egy átlagolással végezhető el, mert az igazi gyök a tipp és szám/tipp között van.) Ez két belső függvényt tartalmaz, amelyek a külső függvény x változóját is látják. Emlékezz vissza: ez egy ún. „downwards funarg” probléma. A változó még létezik, de nem a hívott függvény keretében van, hanem fentebb, valamely hívóéban.

function heron(x) {
    function good_enough(guess) {
        return Math.abs(guess*guess - x) < 0.001;
    }
    function improve(guess) {
        return (guess + x/guess)/2.0;
    }
    var guess = 1.0;
    while (!good_enough(guess))
        guess = improve(guess);
    return guess;
}

Hogy lehet megcsinálni ugyanezt egy objektummal C++-ban?

int main() {
    Heron h;
    std::cout << h.get(2.0);
}

Figyelem: nem kell más felépítésű programot írnod, mint a fenti! Szinte ugyanezek a függvények lesznek, szinte csak szintaktikai átalakításról van szó.

Megoldás

A belső függvényekből tagfüggvények lesznek. A minden tagfüggvény által látható változókból pedig tagváltozók.

#include <iostream>
#include <cmath>

class Heron {
    bool good_enough(double guess) const {
        return std::abs(guess * guess - x_) < 0.001;
    }
    double improve(double guess) const {
        return (guess + x_ / guess) / 2.0;
    }
public:
    double get(double x) {
        x_ = x;
        double guess = 1.0;
        while (!good_enough(guess))
            guess = improve(guess);
        return guess;
    }
private:
    double x_;
};

int main() {
    Heron h;
    std::cout << h.get(2.0);
}

// Lambdákkal még hasonlóbb megoldás készíthető:

#include <iostream>
#include <cmath>

double heron(double x) {
    auto good_enough = [=] (double g) {
        return std::abs(g*g - x) < 0.001;
    };
    
    auto improve = [=] (double g) {
        return (g + x/g)/2.0;
    };
 
    double guess = 1.0;
    while (!good_enough(guess))
        guess = improve(guess);
    return guess;
}

int main() {
    std::cout << heron(2.0);
}

2. Numerikus deriválás

Adott az alábbi függvény JavaScriptben. Ez paraméterként egy valós→valós függvényt vesz át. Visszatérési értéke is egy valós→valós függvény, amely az előbbi numerikus deriváltja (0,001 lépésközű differenciahányadossal közelítve). Itt egy „upwards funarg” problémát látsz: a visszaadott derivalt() függvény miatt nem szabadna felszabadítani az f és dx lokális változókat.

function derival(f) {
    var dx = 0.001;
    function derivalt(x) {
        return (f(x+dx) - f(x)) / dx;
    }

    return derivalt;
}
  • Írd meg ugyanezt C++-ban! Egy osztályt kell írnod, amelynek konstruktora egy valós-valós függvényt vesz át, és egy olyan objektumot hoz létre, amelynek van függvényhívó operátora. Ha helyes a derivált, akkor az alábbi program 0-hoz közeli értékeket ír ki, mert sin() deriváltja cos():

    #include <iostream>
    #include <iomanip>
    #include <cmath>
    
    int main() {
        Derival my_cos = Derival{sin};
        for (double f = 0; f < 3.1415; f += 0.1)
            std::cout << std::setw(20) << my_cos(f)-cos(f) << std::endl;
    }
    Megoldás

    A paraméterként kapott függvényt el kell tárolni az objektumban. Ugyanígy kell tenni az összes változóval.

    #include <iostream>
    #include <iomanip>
    #include <cmath>
    
    class Derival {
      private:
        using fvtype = double (*)(double);
        fvtype f_;
        double dx_;
    
      public:
        Derival(fvtype f): f_{f}, dx_{0.001} {
        }
        double operator() (double x) {
            return (f_(x+dx_) - f_(x)) / dx_;
        }
    };
    
    int main() {
        Derival my_cos = Derival{sin};
        for (double f = 0; f < 3.1415; f += 0.1)
            std::cout << std::setw(20) << my_cos(f)-cos(f) << std::endl;
    }
    
  • Mi a helyzet, ha a deriválandó függvény a programkódban nem függvény típusú, hanem valami más? Például ha paraméterezhető parabolát szeretnénk: f(x)=ax2+bx+c, ahhoz létrehozható egy Parabola objektum. Ennek szintén a függvényhívó operátora végzi el a számítást:

    Parabola p1{0.5, 2.3, -5};  /* 0.5x^2 + 2.3x - 5 */
    for (double f = 0; f < 3.0; f += 0.1)
        std::cout << std::setw(20) << p1(f) << std::endl;

    Hogy lehet egy ilyet deriválni? Hogyan kell sablonná alakítani a deriváló osztályt? Írd meg a parabolát, és írd át a deriváló osztályt! Az alábbi programnak 0-hoz közeli értékeket kell kiírnia, p1 numerikus p1_der és analitikus p2 deriváltjának különbségét:

    Parabola p1{0.5, 2.3, -5};  /* 0.5x^2 + 2.3x - 5 */
    ??????? p1_der = ???????;
    
    Parabola p2{0, 1, 2.3};     /* x + 2.3, p1 deriváltja */
    for (double f = 0; f < 3.0; f += 0.1)
        std::cout << std::setw(20) << p1_der(f)-p2(f) << std::endl;
    Megoldás
    #include <iostream>
    #include <iomanip>
    #include <cmath>
    
    class Parabola {
      public:
        Parabola(double a, double b, double c) : a_{a}, b_{b}, c_{c} {
        }
        double operator () (double x) {
            return a_*x*x + b_*x + c_;
        }
      private:
        double a_, b_, c_;
    };
    
    template <typename FVTYPE>
    class Derival {
      private:
        FVTYPE f_;
        double dx_;
    
      public:
        Derival(FVTYPE f): f_{f}, dx_{0.001} {
        }
        double operator() (double x) {
            return (f_(x+dx_) - f_(x)) / dx_;
        }
    };
    
    int main() {
        Parabola p1{0.5, 2.3, -5};  /* 0.5x^2 + 2.3x - 5 */
        Derival<Parabola> p1_der = Derival<Parabola>{p1};
    
        Parabola p2{0, 1, 2.3};     /* x + 2.3, p1 deriváltja */
        for (double f = 0; f < 3.0; f += 0.1)
            std::cout << std::setw(20) << p1_der(f)-p2(f) << std::endl;
    }
    

3. A kifejezésfa okos pointerrel

Innen letölthető a kifejezéseket kiértékelő és deriváló program: expression.cpp. Emlékezz vissza:

  • Ez kifejezéseket (Expression), azon belül konstansokat (Constant), változókat (Variable), összegeket (Sum) és szorzatokat (Product) tudott tárolni. Az utóbbi két osztályt a kód egy közös TwoOperand ősosztállyal valósítja meg.
  • A kétoperandusú műveletek lényegében egy kételemű heterogén kollekciót tartalmaznak: Expression *lhs_, *rhs_. Ezek erőforráskezelése miatt destruktorra volt szükség.
  • A szorzat deriválásánál: (ab)' = a'b + ab', a keletkező kifejezésben felhasználjuk a bal és a jobboldali operandusnak nem csak a deriváltját, hanem a másolatát is. Emiatt szükség volt az ismeretlen típusú objektum másolására, amit virtuális konstruktorral lehetett megoldani: ez lett a clone() függvény.

Feladat:

  • Írd át úgy a programot, hogy a nyers pointerek és a kézi erőforráskezelés helyett std::shared_ptr-t használjon! Mindenhol, ahol eddig sima pointer volt, ott shared_ptr-nek kell lennie.
  • Mely függvények szűnnek meg ezáltal?
  • Használd az std::make_shared<T>(...) függvényt! Ez dinamikusan lefoglal és shared_ptr-be csomagol egy T típusú objektumot, a ... helyén megadott értékeket a konstruktornak átadva. Pl. az alábbi két sor egyenértékű:
    return std::shared_ptr<Constant>(new Constant{3.14});
    return std::make_shared<Constant>(3.14);
  • Miután ez elkészült, gondolkodj el rajta, hogy a szorzat deriválásánál tényleg szükséges-e a másolás, vagy a részfákat meg lehet-e osztani az egyes kifejezések között. Tehát hogy tényleg szükséges-e a mély másolat, vagy elegendő a sekély másolat is. Ha elegendő a sekély másolat, akkor a clone() teljesen kitörölhető, a megosztást pedig a shared_ptr kezelni fogja.
  • Vannak olyan kommentek a kódban, amelyeket ki kellene törölni?
Megoldás

A TwoOperand erőforráskezelő függvényei (destruktor, másoló konstruktor) teljesen eltűnnek.

A kifejezésfák megoszthatók. A sekély másolat azért engedhető meg, mert a kifejezések soha nem módosulnak, az összes objektum értéket jelképez, konstans. Ha lenne pl. Constant::set_value(), vagy Sum::set_left_operand() függvény, akkor ez nem lenne igaz!

A dinamikus memóriakezeléssel kapcsolatos kommenteket törölni kell – a shared_ptr-ek segítségével automatikussá vált a memóriakezelés, így teljesen lényegtelen, hogy a háttérben dinamikus memóriakezelés van. Észre kell venni: az átalakított kódban sehol sem szerepel sem a new, sem a delete szó!

A teljes megoldás: expression_shared.cpp.

4. RAII: a FILE* becsomagolása

FILE* a C++-ban is van, a cstdio fejlécfájlt kell használni hozzá. A szokásos függvényei is megvannak, fopen(), fclose(), fprintf(), tökéletesen ugyanaz minden, mint C-ben.

A megnyitott fájl egy olyan erőforrás, amit nem a delete operátorral kell felszabadítani, így a beépített okos pointerek nem jók hozzá. A feladatod egy olyan osztályt írni, amely RAII becsomagol egy FILE* pointert. Az objektumot át kell tudni adni a szokásos C-s függvényeknek. Az alábbi főprogramnak kell működnie, és a fájlnak automatikusan be kell záródnia:

int main() {
    FilePtr fp;
    
    fp = fopen("hello.txt", "wt");
    fprintf(fp, "Hello vilag");
}
  1. Milyen operátort kell írni az osztálynak ahhoz, hogy az fopen()-es sor működjön? Figyeld az értékadás két oldalán álló típusokat!

  2. Milyen operátor kell ahhoz, hogy az fprintf()-es sor működjön?

  3. FilePtr objektumokat nem szabad lemásolni és értékül adni, mert akkor a bennük tárolt FILE* átmásolódna az új objektumba, és kétszer lenne bezárva egy fájl. Tiltsd le a másoló konstruktort és az értékadó másolás operátort!

  4. Aki nem ismeri ezt az osztályod pontosan, és nem tudja, hogy az fclose() automatikusan meghívódik a destruktorból, esetleg megpróbálhatná kézzel meghívni a függvényt: fclose(fp). Márpedig ha az fprintf()-es sor lefordul, akkor ez is, és kétszer záródik be a fájl. Meg lehet valahogyan oldani, hogy ilyet ne engedjen a fordító?

Megoldás

Az fclose()-hoz: Kell lennie egy olyan fclose() nevű függvénynek, amely konverzió nélkül is át tud venni egy FilePtr-t. Ha van ilyen, akkor az fclose(fp)-nél azt fogja használni a fordító. A konverzió nélküli értelmezésnek mindig elsőbbsége van. Megírni a függvényt nem kell, legjobb = delete deklarálni.

#include <cstdio>

class FilePtr {
  public:
    explicit FilePtr(FILE *fp = nullptr) : fp_{fp} {}
    ~FilePtr() {
        close_if_open();
    }
    FilePtr(FilePtr const &) = delete;
    FilePtr & operator=(FilePtr const &) = delete;
    
    FilePtr & operator=(FILE *fp) {
        close_if_open();
        fp_ = fp;
        return *this;
    }
    operator FILE* () const {
        return fp_;
    }
  private:
    FILE *fp_;
    
    void close_if_open() {
        if (fp_ != nullptr)
            fclose(fp_);
        fp_ = nullptr;
    }
};

void fclose(FilePtr) = delete;

int main() {
    FilePtr fp;
    
    fp = fopen("hello.txt", "wt");
    fprintf(fp, "Hello vilag");
}

5. További feladatok