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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.