Стандартная библиотека потоков ввода/вывода в C++ стара, неудобна и полна ужасов.
Непосредственно с неопределенным поведением при ее использовании столкнуться проблематично, но нервы потрепать можно. И веселье обычно начинается, когда ваш маленький изолированный и совершенно корректный код, использующий std::istream
или std::ostream
, становится частью чего-то большого.
Даже самым рьяным борцам за чистоту функций и иммутабельность все-таки придется смириться с тем, что где-то глубоко под капотом у сущности, через которую осуществляется ввод/вывод, есть какое-то мутабельное состояние. И это нормально.
Не нормально же то, что у этой же самой сущности есть дополнительное мутабельное состояние ответственное за форматирование данных... Механизм манипуляторов. И он кошмарен.
std::cout << std::hex << 10; // `a`, ок
std::cout << 10; // опять `a` ?!?!
Манипулятор меняет состояние потока, переключает режим форматирования для всех последующих операций чтения или записи! И так будет до тех пор, пока не вернут исходное состояние.
auto state = std::cout.flags();
std::cout << std::hex << 10; // a
std::cout.flags(state);
std::cout << 10; // 10, ок
Страшно представить, какой хаос начнется, если кто-то передаст в вашу функцию поток, с переставленными флагами форматирования. Или вы забудете вернуть их в исходное состояние.
Использование одного и того же имени метода для выставления и получения флагов
тоже радует. Особенно любителей возвращать значения через lvalue
-ссылки в аргументах функций. Но это фишка дизайна чуть ли не всего функционала по настройке потоков. Так что терпим.
Ну и, конечно, стейт форматирования — дополнительная возможность пострелять по ногам в многопоточной среде.
Мало нам мутабельного стейта с флагами форматирования. Он хотя бы привязан к конкретному экземпляру i/ostream
. У нас еще и конструирование новых экземпляров завязано на глобальную мутабельную переменную — текущую глобальную локаль.
Локали это, конечно, отдельная головная боль. И не только для C/C++, а вообще Но это далеко за рамками этой серии заметок.
Тут важно лишь то, что i/ostream
s локалезависимые. И еще не только они, но и множество
функций std::to_string
, atof
, strtol
, и прочие прекрасные функции преобразования чего-то к строкам и обратно.
А теперь фокус, демонстрирующий проблему, обнаруживаемую (а потом уныло исправляемую) на каком-то этапе жизни совершенно любой C++ библиотеки, берущейся парсить текстовые форматы данных:
int main(int argc, char **argv) {
auto s = std::to_string(1.5f);
std::istringstream iss1(s);
float f1 = 0; iss1 >> f1;
assert(fabs(f1 - 1.5) < 1e-6); // Ok
std::locale::global(std::locale("de_DE.UTF8"));
std::istringstream iss2(s);
float f2 = 0; iss2 >> f2;
assert(fabs(f2 - 1.5) < 1e-6); // Сюрприз! f2 == 1500000
}
Если вас не впечатлил искусственный пример, то можете обратить внимание на недавний баг в игре Crow Country. В ней, из-за локалезависимого чтения параметров из конфигурационных файлов, грибы заболели ксенофобией и перестали разговаривать с игроками-французами.
UTF8 это прекрасно. UTF8 это хорошо. У вас код, скорее всего, в UTF8. Python строки по умолчанию гоняет в UTF8. Да все кому не лень гоняют в UTF8! 2021 год. Юникод!
А что если вашей C++ программе приходит UTF8-строка с путем к файлу, который надо открыть?
void fun(const std::string& filename) {
std::ifstream file(filename);
}
И все хорошо? И все работает? И кириллица? И китайское письмо? Точно работает? А под Windows? И примерно в этот момент выясняется, что все-таки не работает.
Конструктор std::fstream
, ровно как и сишные fopen
особым умом не отличаются.
Про ваш юникод они ничего знать не знают. И про то, что он от нативной кодировки системы
внезапно может отличаться, не догадываются.
В итоге мы получаем что почти каждая C++ программа сталкивается с багом под Windows: стоит в пути к файлу встретиться не-ASCII-символу, так сразу файл не найден.
Бинарный режим чтения и записи файлов — еще одна отдельная боль, от которой
страдают на самых разных языках. Чтение бинарных данных из stdin
, запись в stdout
(которые по умолчанию открыты в текстовом режиме). Теряющиеся или лишние добавляемые байты CR (\r
) — все как мы любим.
Но в C++ у нас есть дополнительные возможности для страданий.
Я довольно часто встречаю эту ошибку и не только в студенческих работах:
std::ifstream file(name, std::ios::binary);
char x = 0;
file >> x; // считают, что будет чтение одного байта.
Но нет. operator>>
для стандартных типов всегда пытается выполнить форматное чтение.
И по умолчанию все пробельные символы будут пропущены. Более того, у нас в принципе
нет возможности узнать, в каком режиме у нас открыт поток! Только руками где-то сохранять
эту информацию.
Аналогично, но ошибка более быстро проявляется:
std::ifstream file(name, std::ios::binary);
int x = 0;
file >> x; // считают, что будет чтение sizeof(int) байт.
Также очень распространен особо мерзкий случай:
std::ifstream file(name, std::ios::binary);
std::string s;
file.read(reinterpter_cast<char*>(&s), sizeof(s)); // UB!
Неопытные программисты, тестирующие на коротких строках и успокаивающиеся, могут столкнуться с тем, что этот код будет работать «так как они и предполагали». Исключительно из-за особенности современной реализации строк и техники SSO (small string optimization): строка реализуется не просто как три поля (data, size, capacity), но, если строка короткая, она записывается прямо поверх этих полей.
Но конечно же это некорректно.
У потоков ввода/вывода есть еще одни флаги — состояние потока: были ли ошибки,
достигли ли конца. И многие знают, что проверить успешность операции можно, просто засунув объект
потока в условный оператор (или в иной другой контекст, где выполняется приведение к bool
).
std::istringstream iss("\t aaaa \n bb \t ccc dd e ");
std::string token;
int count = 0;
while (iss >> token) {
++count;
}
assert(count == 5); // OK
Тут будут прочитаны все пять токенов из строки. Ни больше, ни меньше.
Если будет ошибка:
std::istringstream iss("1 2 3 рг 5");
int token = 0;
int count = 0;
while (iss >> token) {
++count;
}
std::cout << token; // выведет 0 !
assert(count == 3); // OK
Ну тоже логично. А на токене, на котором произошла ошибка, результат зануляется. Если сильно надо, можно настроить выбрасывание исключений
А что если бинарные данные почитать?
std::istringstream iss("12345");
std::array<char, 4> buf;
int read_count = 0;
while (iss.read(buf.data(), 4)) {
read_count += iss.gcount();
}
assert(read_count == 5); // Упс, последний байт не прочитался
А тут у нас eof при чтении. А значит ошибка. И все равно что один байт-то прочитался успешно.
Ну хорошо, в C есть прекрасные fread
, которые сразу возвращают количество считанных байт и получается красивый цикл.
Может, что-то такое есть и тут? Конечно есть!
std::istringstream iss("12345");
std::array<char, 4> buf;
int read_count = 0;
while (iss.readsome(buf.data(), 4) > 0) {
read_count += iss.gcount();
}
assert(read_count == 5);
Вау, работает!
На самом деле, нет. Идем на cppreference и читаем:
The behavior of this function is highly implementation-specific. For example, when used with std::ifstream, some library implementations fill the underlying filebuf with data as soon as the file is opened (and readsome() on such implementations reads data, potentially, but not necessarily, the entire file), while other implementations only read from file when an actual input operation is requested (and readsome() issued after file opening never extracts any characters).
В общем, не работает. Упражнение по замене istringstream
на ifstream
в примере выше предлагаю читателю проделать самостоятельно.