32.1 iostream, stream state és pufferelés

A C++ stream könyvtár az egyik legsikerültebb „nulla költségű” absztrakció a nyelvben – és egyben az egyik legkönnyebben félreértett. A hibaforrás ritkán a szintaxis; szinte mindig az állapotmodell, a pufferelés és az erőforrás-tulajdonlás határán keletkezik. DevOps szempontból ez közvetlenül érinti a naplózást, a konfigurációfeldolgozást és minden olyan programot, amelynek a kimenete diagnosztikai bizonyíték. Ez az alfejezet ezért nem a formázási flagok katalógusa, hanem a szerződés: mit garantál a stream, mit nem, és hol lépünk át a libc/POSIX világába, ahol a szabályok megváltoznak.

Az alfejezet a stdio, FILE és pufferelés részre épül: ott rögzítettük, hogy a FILE* egy pufferelt Absztrakt gép, amelynek a lezárása és a flush-olása külön művelet. Itt azt vizsgáljuk, hogyan örökli és módosítja ezt a C++ iostream réteg, illetve hogyan kapcsolódik hozzá a RAII wrapper és a hibakód-alapú hibakezelés.

32.1.1 Az iostate állapotmodell

Minden std::basic_ios objektum négy állapotbitet tart nyilván std::ios_base::iostate típusú bitmaszkban:

Bit Jelentés Tipikus kiváltó ok
goodbit nincs hiba (értéke 0) sikeres művelet
eofbit a bemenet végét elértük (akár sikeres olvasás közben is) getline, read a fájl végén; >> az utolsó, lezáró elválasztó nélküli adatnál
failbit a művelet logikailag sikertelen formázási hiba, zárt stream, getline fájlvégkor
badbit a stream integritása sérült eszközhiba, kivétel a pufferrétegben

A négy bit nem független, és nem is hierarchikus. A failbit és a badbit egyszerre is állhat. Az eofbit két különböző helyzetben áll be: (1) sikeres olvasás mellett, ha a művelet a fájl végébe ütközve fejeződött be — például a "42" (záró újsor nélküli) bemenetből az in >> x sikeresen beolvassa a 42-t, és közben eléri a véget: eofbit áll, failbit nem; (2) sikertelen olvasásnál, ha adat már nem maradt — ekkor az eofbit mellé a failbit is beáll. Az eof() igaz volta tehát önmagában nem mond semmit az utolsó művelet sikeréről; azt a fail() (vagy az operator bool) dönti el.

// eof_without_fail.cpp — C++20: eofbit failbit nélkül, majd eofbit|failbit
#include <cassert>
#include <sstream>

int main() {
    std::istringstream in("42");          // nincs záró újsor
    int x = 0;
    in >> x;                              // a szám végét a fájlvég zárta le
    assert(x == 42 && in.eof() && !in.fail() && static_cast<bool>(in));

    int y = 0;
    in >> y;                              // adat nélküli újabb olvasás
    assert(in.eof() && in.fail() && !in.bad() && !static_cast<bool>(in));
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic eof_without_fail.cpp figyelmeztetés nélkül fordul, és mindkét assert teljesül: az első olvasás után x == 42, eof() igaz, fail() hamis, bool(in) igaz; a második után eof() és fail() is igaz.

A lekérdező függvények ezt a maszkot dekódolják: good() akkor igaz, ha egyik bit sem áll; eof(), fail(), bad() a megfelelő bitet vizsgálja. Fontos, hogy a fail() pontosan akkor igaz, ha a failbit vagy a badbit áll; az eofbit nem számít bele. A fail() tehát nem a !good() megfelelője: egy csak eofbit-es stream nem good, de nem is fail. Az operator bool konverzió a !fail() kifejezést adja vissza, ezért while (in >> x) alakban használható.

Az állapotot a clear(state) és a setstate(state) módosítja. A clear() nem törli a hibát „varázsütésre”: csak a maszkot írja felül, a mögöttes eszköz állapotát nem. Egy badbit utáni clear() után a stream formálisan good lesz, de a következő művelet újra hibázhat. Fájl végéről való újraolvasáshoz clear() és seekg() is kell.

32.1.2 Az exception mask és a két hibakezelési stílus

A std::ios_base::exceptions(mask) hívás nem azt jelenti, hogy „ezeket a hibákat kezelem”, hanem azt, hogy ezek a bitek kivételt váltanak ki. Ha a mask olyan bitet tartalmaz, amely az rdstate()-ben már be van állítva, az exceptions() hívás azonnal dob. A mechanizmus tehát kétélű: beállítása után a hiba nem állapotként, hanem std::ios_base::failure kivételként jelenik meg. C++11 óta ez a típus a std::system_error-ből származik, így van code() tagja és errno-szerű hibakódja – ez a híd a system_error világához.

A gyakorlati következmény a legfontosabb: a bemeneti stream maskjába ritkán szabad beletenni a failbit-et. A std::getline és az operator>> a fájl végén failbit-et állít, ami mask esetén kivételt dobna – vagyis a normál fájlvége hibaként viselkedne. A helyes minta:

in.exceptions(std::ios_base::badbit);   // csak valódi I/O hiba dob
while (std::getline(in, line)) { /* ... */ }
if (!in.eof()) {                        // nem a fájl végén állt le
    throw std::runtime_error("olvasási hiba a bemeneten");
}

A kimeneti streamnél viszont a badbit | failbit mask indokolt, mert ott nincs „normál fail”: minden sikertelen írás valódi hiba. A close() hibája is failbit-et állít, így mask mellett a lezárás hibája kivételként jelentkezik – ezért fontos az explicit close(), mert a destruktor nem dob, hanem elnyeli a hibát.

32.1.3 Formázott és formázatlan I/O

A formázott műveletek (operator<<, operator>>) a locale és a formázási flagok szerint alakítanak, és a skipws flag hatására bemeneten átugorják a whitespace-t. A formázatlan műveletek (read, write, get, getline, put, ignore, peek, gcount) nyers karakterekkel dolgoznak, nem alkalmaznak konverziót, és nem érinti őket a skipws. A std::getline(std::string&) szabad függvény, de formázatlan: az újsorig olvas, az újsort eldobja, és a fájl végén eofbit-et és failbit-et állít.

A megkülönböztetés nem esztétikai. Bináris protokollhoz, fájlmásoláshoz és bármilyen bájtszintű szerződéshez a formázatlan API a helyes, mert a formázott olvasás implicit konverziói adatvesztést és locale-függést hoznak be.

32.1.4 Fájlstream és RAII

Az std::ifstream és std::ofstream konstruktora megnyitja, destruktora lezárja a fájlt – ez a RAII minta, amely itt a FILE* kézi fclose-ját váltja fel. A destruktor azonban nem dob és nem jelez: ha a lezárás vagy a flush meghiúsul, arról csak akkor értesülünk, ha magunk hívjuk meg a close()-t, és ellenőrizzük az állapotot vagy a kivételt. Tartós írásnál ezért a helyes sorrend: írás → flush() → close() → hibaellenőrzés.

A stream objektum mozgatható, de nem másolható: a tulajdonlás átadható, a file descriptor nem duplikálódik implicit módon. Egy std::ofstream tehát egyetlen owner, amelynek az élettartama a fájl nyitva tartásának az élettartama.

32.1.5 Pufferelés, flush és a libc határ

A stream saját pufferrel rendelkezik, amelynek kiürítése a flush() művelet. A std::endl nem más, mint '\n' beírása és flush() hívása; a std::ends '\0'-t ír, a std::flush csak flush-ol. A std::cerr unitbuf flaggel indul, azaz minden írás után flush-ol, a std::cout és a std::clog viszont pufferelt. Ezért keveredhet össze a két kimenet, ha a cout-ba írt sor után nem flush-olunk, de a cerr-be azonnal írunk.

A std::cin.tie() alapértéke &std::cout: minden bemeneti művelet előtt a hozzákötött stream flush-ol. Ez teszi működőképessé az interaktív promptot. A tie( nullptr ) oldja a kötést, ami kötegelt feldolgozásnál felesleges flush-okat szüntet meg.

A std::ios_base::sync_with_stdio(false) kapcsolja ki a C és a C++ standard streamek összehangolását; ekkor a std::cin/std::cout saját puffert kap, és a C printf/scanf felé irányuló implicit flush-elés megszűnik. Feltétel: a hívásnak az első I/O művelet előtt kell megtörténnie, különben a szabvány a hatást implementáció által meghatározottnak mondja. A kapcsoló csak a standard streamekre vonatkozik, tetszőleges FILE* és std::fstream viszonyára nem.

Itt lépünk át a libc/POSIX tartományba. A std::ofstream::close() a puffer tartalmát a kernelnek adja át, nem a lemezre. Tartóssághoz fsync() (vagy fdatasync()) kell a mögöttes file descriptoron, ami nem érhető el hordozhatóan az std::ofstream interfészén – GNU libstdc++ esetén a nem szabványos __gnu_cxx::stdio_filebuf ad erre módot, ami implementációfüggő. A hordozható minta a FILE* szintjén:

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

/* POSIX, nem ISO C: a fileno és az fsync Linux/POSIX specifikus. */
int write_durable(const char *path, const char *buf, size_t len) {
    FILE *f = fopen(path, "wb");
    if (!f) return -1;
    if (fwrite(buf, 1, len, f) != len) { fclose(f); return -1; }
    if (fflush(f) != 0)                { fclose(f); return -1; }
    if (fsync(fileno(f)) != 0)         { fclose(f); return -1; }
    if (fclose(f) != 0)                  return -1;
    return 0;
}

Ez a háromrétegű pufferelés (C++ stream → libc FILE → kernel page cache) magyarázza, miért nem elég a stream lezárása a tartóssághoz, és miért kell a naplóknál a fsync költségét külön mérni.

32.1.6 Stringstream, locale és megosztott streamek

A std::stringstream ugyanazt az állapotgépet használja memóriapuffer fölött: str() adja vissza a tartalmat, C++20-tól view() egy nem tulajdonló nézetet. A hibakezelés azonos: a formázási hiba failbit, a clear() utáni újrahasználat pedig újraolvasáshoz seekg(0)-t igényel.

A locale befolyásolja a tizedesjel, az ezres elválasztó és a dátumformátum értelmezését. Fontos részlet, hogy a C++ streamek induláskor a klasszikus "C" locale-t használják, nem a környezeti beállítást; a környezet követéséhez std::locale::global(std::locale("")) kell, illetve streamenként imbue(). A std::sto* függvénycsalád viszont a globális C locale-t használja, nem a stream sajátját – ez gyakori inkonzisztencia-forrás.

A megosztott streamek szálbiztonsága korlátozott: a formázott műveletek egyedi szinten adatverseny-mentesek, de két szál által írt kimenet összefonódhat, és a globális locale beállítása nem szálbiztos. Több szálú naplózáshoz tehát saját lock vagy külön puffer szükséges.

32.1.7 Kidolgozott példa: sorok átszámozása

A példa a fenti szerződéseket együtt mutatja: bemeneti mask csak badbit-en, kimeneti mask badbit | failbit-en, explicit close(), valódi hiba megkülönböztetése a fájlvégtől.

// linelab.cpp – C++20
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <system_error>

namespace fs = std::filesystem;

int main(int argc, char** argv)
{
    if (argc != 3) {
        std::cerr << "használat: " << argv[0] << " <in> <out>\n";
        return 2;
    }
    const fs::path in_path = argv[1];
    const fs::path out_path = argv[2];

    // Előfeltétel: a bemenet és a kimenet különböző fájl. Az equivalent()
    // az inode-ot hasonlítja, így a hard linket és az eltérő útvonalat is
    // felismeri; a puszta útvonal-összehasonlítás nem.
    std::error_code ec;
    if (fs::exists(out_path, ec) && fs::equivalent(in_path, out_path, ec)) {
        std::cerr << "hiba: a bemenet és a kimenet ugyanaz a fájl\n";
        return 2;
    }

    // A kimenet ideiglenes fájlba készül, és csak siker után kerül a helyére.
    // Ha az ellenőrzés és az írás között mégis ugyanarra a fájlra mutatna a
    // kimeneti név, a bemenetet akkor sem csonkítjuk.
    const fs::path tmp_path = out_path.string() + ".tmp";
    try {
        std::ifstream in;
        in.exceptions(std::ios_base::badbit);   // failbit szándékosan kimarad
        in.open(in_path);
        if (!in) throw std::runtime_error("nem nyitható meg a bemenet");

        std::ofstream out;
        out.exceptions(std::ios_base::badbit | std::ios_base::failbit);
        out.open(tmp_path, std::ios_base::out | std::ios_base::trunc);

        std::string line;
        std::size_t n = 0;
        while (std::getline(in, line)) {
            out << ++n << '\t' << line << '\n';   // '\n', nem std::endl
        }
        if (!in.eof()) throw std::runtime_error("olvasási hiba");

        out.close();                              // itt dobhat, ha a flush hibás
        fs::rename(tmp_path, out_path);           // azonos fájlrendszeren csere
        std::cout << "sorok: " << n << '\n';
    } catch (const std::ios_base::failure& e) {
        std::cerr << "I/O hiba: " << e.what()
                  << " (" << e.code().message() << ")\n";
        fs::remove(tmp_path, ec);
        return 1;
    } catch (const std::exception& e) {
        std::cerr << "hiba: " << e.what() << '\n';
        fs::remove(tmp_path, ec);
        return 1;
    }
    return 0;
}

Laborparancs: g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o linelab linelab.cpp.

A program előfeltétele, hogy a bemenet és a kimenet különböző fájl. Ez nem ugyanaz, mint a különböző útvonal: ./linelab a.txt a.txt és egy hard link (ln a.txt b.txt; ./linelab a.txt b.txt) is ugyanarra a fájlra mutat. A naiv változat a kimenetet trunc módban nyitja meg, ezzel a még be nem olvasott bemenetet is lenullázza. A std::filesystem::equivalent az eszköz- és inode-azonosítót hasonlítja össze, ezért mindkét esetet felismeri. Az ellenőrzés és a fájlnyitás között azonban a fájlrendszer megváltozhat (TOCTOU). Ezért a program az ideiglenes fájl + átnevezés mintát is használja: a bemenetet így semmilyen sorrendben nem csonkítja, a kimenet pedig csak sikeres írás után kerül a helyére. A POSIX rename azonos fájlrendszeren atomikus csere; a .tmp utótagú név egyszerűsítés, többpéldányos futásnál egyedi ideiglenes nevet kell választani.

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult. Kétsoros bemenetre sorok: 2 kimenettel, 1\ta, 2\tb tartalmú kimenettel futott. Azonos útvonalra és hard linkre hiba: a bemenet és a kimenet ugyanaz a fájl üzenettel, 2-es kóddal állt le, a forrás 4 bájtja megmaradt. Nem létező és olvashatatlan (chmod 000) bemenetre hiba: nem nyitható meg a bemenet, 1-es kód, ideiglenes fájl nem marad. A stream által dobott failure ága ettől külön tesztelt: írásvédett kimeneti könyvtárnál az out.open a maszk miatt std::ios_base::failure-t dob, a kimenet I/O hiba: ..., szintén 1-es kóddal, std::terminate nélkül. A javítás előtti változat azonos fájlra és hard linkre is 0 bájtra csonkította a forrást, nem létező bemenetre pedig std::terminate-tel (134-es kód) állt le, mert a std::runtime_error-t semmi nem kapta el.

32.1.8 Tipikus hibák

A leggyakoribb hiba a while (!in.eof()) ciklus. Az eofbit csak egy sikertelen olvasás után áll be, ezért a ciklus még egyszer lefut egy üres vagy ismételt sorral. A helyes forma a while (std::getline(...)) vagy while (in >> x).

Második: a failbit bevétele a bemeneti maskba, amitől a normál fájlvég kivételt dob. Harmadik: a close() elhagyása és a destruktorra bízás, amitől a lemezre írás hibája csendben elvész. Negyedik: std::endl használata nagy volumenű naplóban, ahol a soronkénti flush rendszerhívásba kerül. Ötödik: a sync_with_stdio(false) hívása I/O után, amikor a hatás már nem garantált.

32.1.9 Feladatok

1. (magyarázó) Adott a while (!in.eof()) { std::getline(in, line); process(line); } minta. Magyarázd meg, milyen tünetet okoz a fájl utolsó sora körül, és vezesd le a javítást.

2. (kódolási) Írj read_config függvényt, amely egy kulcs=érték fájlt olvas be, a hibás sorokat sorszámozva jelzi a std::cerr-en, a valódi I/O hibát pedig kivétellel továbbítja. A maskot és az eof-kezelést indokold.

3. (diagnosztikai és mérési) Készíts mérési tervet annak eldöntésére, hogy egy naplózó programban az std::endl helyett '\n' használata és a sync_with_stdio(false) bekapcsolása mekkora költségcsökkenést ad. Add meg a független változókat, a kontrollelemeket és a buktatókat.

32.1.10 Megoldások

1. Az eofbit nem az utolsó sikeres olvasás pillanatában, hanem az azt követő sikertelen olvasáskor áll be. A ciklus tehát az utolsó sor után még egyszer belép, a getline nem módosítja a line-t (vagy üresre állítja), és a process lefut egy hibás adaton. Javítás: a getline visszatérési értékét kell feltételnek tenni, mert az operator bool a !fail()-t adja, és a fájlvég éppen failbit-et állít. Ellenőrzési feltétel: az utolsó sor pontosan egyszer, a process pedig nem hívódik meg a fájlvég utáni üres iterációban.

2. A bemeneti mask badbit legyen, mert a fájlvég nem hiba; a ciklus után if (!in.eof()) throw ... adja a valódi hibát. A hibás sorokat a std::cerr-re írjuk, mert az unitbuf miatt azonnal megjelenik, és nem keveredik a pufferelt std::cout-tal. A std::getline után ellenőrizzük a '=' jelenlétét és a kulcs nem üres voltát – ez logikai hiba, nem stream-hiba, ezért nem failbit-et állítunk, hanem saját számlálót és üzenetet használunk. Ellenőrzési feltétel: hiányzó fájl esetén kivétel, hibás sor esetén a program nem áll le, és a jó sorok feldolgozódnak.

3. Független változó: a lezáró karakter ('\n' vs std::endl) és a sync_with_stdio be/ki. Függő változó: a teljes futási idő nagyszámú, azonos hosszúságú sor írásakor, illetve a rendszerhívások száma (strace -c). Kontroll: azonos fájlméret, azonos fájlrendszer, meleg page cache, több ismétlés, medián és szórás. Buktatók: a lemezre írás költsége elfedheti a flush-hatást, ezért először /dev/null-ra vagy tmpfs-re írjunk; a '\n' és az std::endl összehasonlításánál gondoskodni kell arról, hogy a program végén legyen egyetlen záró flush, különben nem ugyanazt mérjük. A mérés eredménye nem általánosítható minden streamre és implementációra, ezért a compiler és a standard library verzióját rögzíteni kell.

32.1.11 Összefoglaló

A C++ stream állapotmodellje négy bitből áll, amelyek nem hierarchikusak: az eofbit fájlvégkor failbit-tel együtt jelenik meg, a badbit pedig integritáshiba. Az exception mask nem hibakezelés, hanem hibakiváltás: a bemeneti maskból a failbit-et ki kell hagyni, a kimenetiből nem. A std::endl flush-ol, a '\n' nem; a close() hibája csak explicit híváskor derül ki. A stream lezárása a kernelnek adja át az adatot, nem a lemezre – a tartóssághoz fsync kell, ami már a POSIX tartomány. A sync_with_stdio(false) és a tie(nullptr) költségcsökkentő eszközök, de csak az első I/O előtt, illetve a standard streamekre érvényesek.