I/O manipulátorok

Czirkos Zoltán · 2022.06.21.

Az std::setw-hez hasonló manipulátorok írása.

Folytassuk az előadás mértékegységes példáját!

1. Manipulátorok mint segédobjektumok

Tekintsünk két egyszerű fizikai problémát: a libikókát és egy doboz vonszolását.

A libikóka akkor van egyensúlyban, ha a két oldalon ható forgatónyomaték (torque) megegyezik. Ezt az erő és az erőkar vektoriális szorzataként tudjuk kiszámolni, ennek mértékegysége az erő×hosszúság miatt Newton×méter, azaz Nm. A doboz vonszolása közben végzett munkát (work), a befektetett energiát az erő és az elmozdulás skaláris szorzásával tudjuk kiszámolni. Itt is egy erő és egy hosszúság szorzódik össze, ennek mértékegyége is Newton·méter, azonban ezt J-vel jelöljük, Joule nevéből.

A két mennyiség (legalábbis az abszolút értékük) mértékegysége tehát megegyezik, ezért a két mennyiséget a C++ programunkban ugyanaz a típus jelképezi. Ez a számításoknál rendjén is van, azonban ha a kiírást az energia típusra specializáljuk, hogy J mértékegységben íródjanak ki az értékek, hirtelen a forgatónyomatékok is elkezdenek J-ként kiíródni. Amit viszont nem szeretnénk, mert értelmetlen. Hiába, a fordító nem tudja megkülönböztetni őket, mert a két érték típusa ugyanaz:

using Energy = Quantity<2, 1, -2>;         /* energia, J=m^2*kg/s^2 */
using Torque = Quantity<2, 1, -2>;         /* forgatónyomaték, Nm=m^2*kg/s^2 */

template <>
std::ostream & operator<< <>(std::ostream & os, Energy m) {
    os << m.magnitude << " J";
    return os;
}

Torque N = 120.0_N * 1.8_m;
std::cout << N << std::endl;               // „216 J” :(

Ezzel a problémával már találkoztunk, és a szabványos könyvtárban néhány esetre meg is van oldva. Például egy számot ki tudunk írni tízes és tizenhatos számrendszerben is. Pedig a hívás ugyanarra az operátorfüggvényre kell feloldódjon, hiszen mindkét esetben egész típusú értékkel etetjük az std::cout-ot:

std::cout << std::hex << 255 << std::endl;
std::cout << std::dec << 255 << std::endl;

Az std::ostream-ekhez tartozik egy olyan operator<< overload, amely egy std::ostream-et paraméterként váró és visszaadó függvényre mutató pointert tud átvenni. Egy ilyenbe betehetünk egy olyan programrészt, amely az std::ostream objektum valamilyen paraméterét átállítja. Az std::hex is ehhez hasonlóan működik:

std::ostream & myhex(std::ostream & os) {
    os.flags(std::ios::hex);
    return os;
}

std::cout << myhex << 15;

Azonban ez most nekünk nem lesz jó, mert nincs olyan tagfüggvénye az std::ostream-nek, amit meghívhatnánk, és amivel jelezhetnénk, hogy a saját, Quantity<2, 1, -2> típusú értékünket hogyan kezelje. Valami más megoldást kell keresnünk.

Nézzük meg jobban a kifejezést, amit le szeretnénk írni!

Torque N = 120.0_N * 1.8_m;
Energy E = 120.0_N * 1.8_m;
std::cout << newtonmeter << N << std::endl;
std::cout << joule << E << std::endl;

A kétkacsacsőr << operátor balról jobbra asszociatív, ezért a fenti kifejezések lényeges részét így kell értelmezni:

((std::cout << newtonmeter) << N)

Itt az std::cout típusa std::ostream, az N típusa Quantity<2, 1, -2>. A newtonmeter típusát szabadon megválaszthatjuk. Az std::cout << newtonmeter „kiírás” hatására ugyanis nem kell semmi láthatónak történnie. Sőt nem is szabad, mert a tényleges kiírandó érték az N-ben van. A newtonmeter-nek azért érdemes saját típust adnunk, mert akkor ahhoz egy új operator<< függvényt adhatunk meg.

Azonban ez még nem elegendő. Ha a newtonmeter objektum „kiírása”, azaz a std::cout << newtonmeter részkifejezés kiértékelésével a függvény a szokásos módon visszatér az std::cout-tal, akkor nincs hova feljegyezni azt az információt, hogy a következő Quantity<2, 1, -2>-ot newtonméterként vagy joule-ként kell kiírni. Ezért az std::cout << newtonmeter függvényhívás visszatérési értékének is egy új, másik típussal kell rendelkeznie.

A működés tehát a következő:

/* 1. */ ((std::cout << newtonmeter) << N) << std::endl
/* 2. */ (TempObj{std::cout, newtonmeter} << N) << std::endl
/* 3. */ (std::cout) << std::endl;
  1. Először meghívódik az az operator<<, amelyik „kiírja” a newtonmeter-t – legyen annak típusa akármi is.
  2. Aztán ez a függvény visszatér egy olyan objektummal, amelybe bele van kódolva, hogy melyik std::ostream-re, milyen formában kell kiírni a következő Quantity<2, 1, -2>-t.
  3. A következő operator<< az N-re vonatkozik, de a bal oldalán nem az std::ostream van, hanem az ideiglenes objektum. Ez az operátor elvégzi a tényleges kiírást, és visszatér az std::ostream-mel.
  4. Az std::ostream-mel való visszatérés miatt pedig a kiírás folytatódhat a további << operátorok által.

Az első operator<< függvényt a szokásos módon, globális függvényként kell megvalósítani, mert ennek bal oldali argumentuma az std::ostream osztálybeli objektum. A második operátort, amely a tényleges kiírást végzi, azonban megvalósíthatjuk az ideiglenesen létrehozott objektum tagfüggvényeként is, mivel annak, saját osztályunk lévén, olyan tagfüggvényt írunk, amilyet csak szeretnénk. A konkrét mértékegységet meghatározó érték lehet akár egy enum is. A teljes megvalósítás:

class NewtonMeterPrinter {
  public:
    enum Unit {
        newtonmeter,
        joule,
    };
    NewtonMeterPrinter(std::ostream &os, Unit unit)
        : os(os), unit{unit} {
    }
    /* ez kezeli a második operator<<-t */
    std::ostream & operator<< (Quantity<2, 1, -2> q) const {
        os << q.magnitude << ' ';
        switch (unit) {
            case newtonmeter:
                os << "Nm";
                break;
            case joule:
                os << "J";
                break;
        }
        return os;
    }
  private:
    std::ostream & os;
    Unit const unit;
};

/* ez kezeli az első operator<<-t */
NewtonMeterPrinter operator<< (std::ostream &os, NewtonMeterPrinter::Unit unit) {
    return NewtonMeterPrinter{os, unit};
}

Itt könnyű belefutni a C++11 egyik bug-jába, nevezetesen hogy referencia nem inicializálható {} szintaxissal. Ez a szabvány szövegezésében volt egy hiba, amit azóta kijavítottak. Az legújabb fordítókkal ezért működne a fenti kódban az os{os} is, ahogy számítunk is rá.

A használat pedig pont úgy néz ki, mint az std::hex-nél és az std::dec-nél:

std::cout << "Energy vs. Torque ===" << std::endl;
Torque N = 120.0_N * 1.8_m;
std::cout << NewtonMeterPrinter::newtonmeter << N << std::endl;
Energy E = 120.0_N * 1.8_m;
std::cout << NewtonMeterPrinter::joule << E << std::endl;

Látszik, hogy az objektumok okosabbak tudnak lenni, mint a függvények. A függvények csak a paramétereiken keresztül látják a külvilágot, az objektumokba azonban bármilyen adatot el tudunk rejteni.

Az így kiegészített változat: quantity4.cpp.

2. Feladatok

Lóerő vagy Watt?

A teljesítményt (energia/idő) néha wattban (W), néha lóerőben (HP) adjuk meg. 1 lóerő = 745,699872 watt. Specializáld a kiíró operátort, hogy a teljesítményeket általában wattban írja ki! Írj egy I/O manipulátort, amely segítségével lóerőben is kiírható egy teljesítmény! Becsüld meg a programban, hány lóerős lehet a Ferrari (lásd: quantity4.cpp), azaz a 4 másodperces gyorsítás alatt mekkora volt az átlagos teljesítménye!

Bináris számok

Írj bináris szám kiíró I/O manipulátort! A használata legyen az alábbi. Vajon mi lehet itt a bin típusa?

std::cout << bin << 240 << std::endl;        /* 11110000 */

Írj bináris beolvasó I/O manipulátort! Olvassa ez addig a bemenetet, amíg 0 és 1 karaktereket kap. Figyelj arra, hogy a következő karaktert ne egye meg a beolvasás! Nézz utána az istream::get() és istream::putback() tagfüggvényeknek, hogy lásd, hogyan kell ezt megoldani. Lehet ezt a programod egyesíteni a bináris kiíró manipulátoros programmal?

unsigned int i;
std::cin >> bin >> i;           /* input = 11110000 -> i = 240 */

Számrendszerek

Írj akárhányas számrendszerben kiíró I/O manipulátort!

std::cout << base(2) << 240;    /* 11110000 */
std::cout << base(16) << 240;   /* F0 */

Melyik az első részkifejezés, amit ki kell értékelnie a fordítónak? Indulj ki ebből!