Ввод/вывод строк в стандартные потоки

Практический курс по C/C++: https://stepik.org/course/193691

Смотреть материал на YouTube | RuTube

Теперь, когда мы узнали, как они задаются и инициализируются, давайте рассмотрим работу функций ввода/вывода строк в стандартные потоки. И начнем с функций вывода.

Функция printf()

Когда мы с вами говорили о функции printf(), то отмечали спецификатор %s для вывода строк. Собственно, с его помощью строки и выводятся. Например:

#include <stdio.h>
 
int main(void) 
{
         char sp[] = "Hello, World";
         printf("sp = %s\n", sp);
                   
         return 0;
}

Будет выведена ранее объявленная строка sp в консоль, следующим образом:

sp = Hello, World

Давайте детальнее разберемся, как это работает. Вначале функция printf() анализирует форматную строку и встречает в ней спецификатор %s. На это место должна быть подставлена строка. Значит, следующим аргументом должен фигурировать указатель, содержащий адрес строки. Именно его мы и передаем при вызове функции printf(). Как мы помним, имя массива – это указатель на его начало. В данном случае, на начало строки. Затем, внутри функции printf() отрабатывает алгоритм, который перебирает последовательно все символы строки и подставляет их на место спецификатора %s, пока не дойдет до символа конца строки. Как только встречается символ ‘\0’, формирование выходной строки завершается и она выводится в выходной поток stdout и, в нашем случае, с последующим выводом на экран.

Аналогичным образом все работает и при указании строкового литерала в функции printf(). Например:

printf("name: %s\n", "Balakirev");

Строка "Balakirev" размещается в виде байтового массива в памяти компьютера при загрузке программы, а при вызове функции printf() передается лишь ее адрес. Дальше все происходит ровно так, как и с обычным массивом.

Функция puts()

Функция printf() обычно используется для форматного вывода информации в стандартный поток stdout. Однако в практике программирования часто нужно просто вывести строку без какого-либо форматирования, так как она есть. Для этого лучше воспользоваться функцией puts(), которая определена в заголовочном файле stdio.h. Этой функции не нужно анализировать форматную строку, поэтому она работает несколько быстрее функции printf(). Пользоваться функцией puts() очень просто, например:

char str[] = {'B', 'a', 'l', 'a', 'k', 'i', 'r', 'e', 'v', '\0'};
puts(str);
puts("Hello!");

После запуска программы увидим результат вывода:

Balakirev
Hello!

Обратите внимание, после каждого вывода функция puts() автоматически добавляет символ переноса строки ‘\n’, так как строки обычно нужно выводить каждую с новой позиции.

Функция scanf() для считывания строк

В целом функции printf() и puts() безопасны для вывода строк. Если строка является корректной, то никаких неожиданностей не возникает. Если же в конце строки по какой-то причине отсутствует символ ‘\0’, то худшее, что может произойти – это вывод в выходной поток stdout различного шума, записанного в последующих ячейках памяти, пока в какой-нибудь из них не встретится значение 0.

Гораздо хуже в языке Си обстоят дела с функциями чтения строк из стандартного входного потока stdin. Одна такая функция нам уже известна и называется scanf(). Для считывания строк используется все тот же спецификатор %s следующим образом:

#include <stdio.h>
 
int main(void) 
{
         char bf[10];
         scanf("%s", bf);
                   
         return 0;
}

Давайте в деталях разберемся, как здесь будет работать функция scanf(). Первое, что обращает на себя – это отсутствие амперсанда (&) перед именем переменной bf. До этого, мы всегда его прописывали, когда указывали переменные базовых типов (int, short, char, double, …). Я, думаю, вы уже догадались, с чем это связано? Да, в качестве параметров после форматной строки функции scanf() нужно передавать адреса переменных, а не их значения. Амперсанд перед именем переменной, как раз, возвращает ее адрес. Как мы теперь знаем, имея этот адрес, значение переменной достаточно просто изменить, записав в нее, таким образом, прочитанные данные из входного потока. То же самое и с массивом. Нам нужно передать его адрес. И, так как имя массива – это и есть указатель на него, то никакого амперсанда дополнительно прописывать не нужно.

Теперь, как непосредственно происходит считывание строки по спецификатору %s. Предположим, мы ввели с клавиатуры строку:

Hello World

Эта строка попала в буфер входного потока stdin. Затем, используя указатель на переданный массив символов, функция scanf() побайтно читает данные из входного потока и заносит по порядку в элементы массива. И делает это до тех пор, пока не встретится пробельный символ (это, собственно, пробел, а также символы табуляции и переноса строки). В конце функция scanf() дописывает ноль, чтобы строка была корректной с позиции языка Си.

В результате здесь имеем две проблемы. Первая. Строка читается не целиком, а лишь до пробельного символа. И это поведение никак нельзя изменить. Вторая проблема связана с тем, что если бы не было пробела после слова «Hello», то чтение из потока stdin продолжилось бы и функция scanf() стала бы заносить данные в ячейки памяти, не отведенные под массив bf. Ни к чему хорошему такое поведение не приводит. Программа, в лучшем случае, завершится аварийно, а в худшем – функция scanf() станет источником уязвимости программы.

К счастью, эту вторую проблему можно относительно просто решить, если у спецификатора %s дополнительно прописать максимальную ширину чтения данных, например, так:

char bf[10];
scanf("%9s", bf);
puts(bf);

В этом случае максимум будет прочитано 9 символов, а в следующий последний 10-й функция scanf() автоматически записывает символ конца строки ‘\0’. Об этом символе всегда следует помнить, указывая максимальную длину. В нашем примере массив bf содержит всего 10 элементов, значит, только в первые 9 можно заносить символы строки, а на место 10-го – символ конца строки. Конечно, если пробельный символ будет встречен раньше 9-й позиции, то считывание остановится на нем. Ширина здесь никак на это не влияет.

Указание максимальной ширины чтения данных, на самом деле, не очень удобно с практической точки зрения. Мы здесь явно вынуждены прописывать конкретные числовые значения, исходя из длины массива. Использовать переменные в записи спецификатора не получится. Конечно, из этой ситуации тоже можно выйти, формируя форматную строку на программном уровне и подставляя, затем, в функцию scanf(). Но это дополнительные неудобства. Поэтому, в целом, использование scanf() для считывания строк не самый лучший подход.

Функция gets() – источник уязвимостей

Начинающие программисты, впервые столкнувшись с задачей чтения строк, начинают искать другие подобные стандартные библиотечные функции. И, конечно же, наталкиваются на функцию gets(), которая читает строку целиком в массив по переданному адресу buf вместе с пробельными символами, пока не встретится символ переноса строки:

char* gets(char* buf);

В конце эта функция также дописывает символ нуля для формирования корректной строки.

Казалось бы, эврика! Вот она заветная функция для чтения произвольных строк. Бери и используй в своих программах. Вначале так и делали. Пока не произошло событие, поставившее крест на этой и подобных ей функциях. Было замечено, что ранние коды программы ОС Unix применяют функцию gets(). Недолго думая, «плохие» парни решили воспользоваться этой уязвимостью и создали вредоносную программу в виде «червя». Такая программа могла легко распространяться по компьютерам, задавая длинную последовательность символов. Эти символы читались функцией gets() и затирали часть кода самой ОС, пробивая в ней брешь. Конечно, с тех пор ОС Unix была неоднократно переписана и теперь такой уязвимости в ней нет. А вот от функции gets() с тех пор совсем отказались. Ее не рекомендуется использовать даже в своих приложениях, чтобы потом, по привычке, не дай бог использовать в коммерческом проекте. Лучше забудьте, что такая функция вообще существует. Именно по этой причине мы не будем ее рассматривать даже на простых примерах.

Безопасные способы чтения строк из стандартного потока stdin

Получается, что функции scanf() и gets() не обеспечивают нас необходимым и безопасным функционалом для чтения строк. Какую же функцию нам тогда взять? На самом деле язык Си стандарта C99 и более ранних не имеет подходящих функций чтения строк из стандартного входного потока. Как же нам тогда быть? Как вариант, можно воспользоваться функцией:

char* fgets(char* buf, int max_len, FILE* stream);

которая предназначена для чтения строки из произвольных потоков, как правило, из файлов. Ее плюс в том, что здесь помимо адреса массива (буфера) указывается максимальная длина max_len  для чтения данных из потока stream. Благодаря этому мы можем гарантировать запись данных только в пределах массива. Например, этой функцией можно воспользоваться так:

char bf[10];
fgets(bf, sizeof(bf), stdin);

Здесь последним аргументом указан стандартный входной поток в виде макроимени stdin, определенное в заголовочном файле stdio.h. Сама же функция fgets() читает максимум max_len-1 символ. Чтение останавливается, когда будет встречен символ перевода строки ‘\n’, либо конец файла EOF, либо прочитано max_len-1 символов из потока stdin. При этом функция fgets() формирует корректную строку, то есть, автоматически добавляет символ конца строки после последнего прочитанного символа. Главным ее неудобством при чтении строк из стандартного потока stdin является то, что она оставляет символ перевода строки ‘\n’ и нам его приходится убирать самостоятельно.

Другой подход заключается в использовании функции:

int getchar(void);

о которой мы с вами уже говорили. Она позволяет посимвольно читать данные из входного потока stdin. Например, с ее помощью можно в цикле читать символы, пока не дойдем до переноса строки, конца файла или максимальной длины:

#include <stdio.h>
 
int main(void) 
{
         char bf[10];
 
         int max_len = sizeof(bf), i = 0;
         char *ptr = bf, ch;
 
         while((ch = getchar()) != '\n' && ch != EOF && i < max_len-1)
                   ptr[i++] = ch;
 
         ptr[i] = '\0';
 
         puts(bf);
                   
         return 0;
}

Обратите внимание, что в конце мы прописываем символ конца строки ‘\0’, чтобы строка в массиве bf была корректной. Именно поэтому из входного потока максимум читается max_len-1 символ (последним обязательно должен быть ‘\0’).

Надо сказать, что в современном языке С++ эта проблема решена введением новых функций вида:

scanf_s(), gets_s()

в которых дополнительно можно задавать максимальный размер читаемых данных. Но если вы пишите программу на языке Си, ориентированную на стандарт C99, то выход только один: в создании своих собственных вариантов функций для чтения строк из стандартного входного потока.

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме