Текст как массив символов с нулём в конце — и почему из-за этого нуля взламывают программы
fgets и объяснять, чем опасен scanf("%s")strlen, strcmp<string.h> и <ctype.h> и знать их ловушкиstrtol)char с нулём в концеОтдельного типа «строка» в C нет. Строка — массив char, в котором после последнего символа стоит завершающий ноль — символ с кодом 0, записывается '\0'. Длина нигде не хранится: её узнают, дойдя до нуля.
char word[8] = "code";
индекс 0 1 2 3 4 5 6 7
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
word │ 'c' │ 'o' │ 'd' │ 'e' │ '\0'│ 0 │ 0 │ 0 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
длина строки 4, размер массива 8: поместится строка до 7 символов
"code" — 5 байт: четыре буквы и ноль. char s[] = "code"; создаёт массив из 5 элементов.'a' — символ (число 97), "a" — строка из двух байт: 97 и 0.const char *p = "code"; — указатель на литерал. Литерал лежит в памяти только для чтения: p[0] = 'C' — неопределённое поведение (обычно аварийное завершение). Изменяемая строка — массив: char s[] = "code"; s[0] = 'C';.scanf("%s", name) читает одно слово до пробела и записывает его в name сколько бы символов ни ввели. Если массив — 8 байт, а введено 20 символов, лишние 13 запишутся в память за массивом — в соседние переменные и служебные данные функции, в том числе в адрес возврата из неё. Это переполнение буфера — классический способ взлома программ на C: подобранный ввод заставляет программу выполнить код нападающего. Функцию gets, которая так читала целую строку, убрали из стандарта C11.
Безопасный ввод строки — fgets(buf, size, stdin): читает строку целиком, с пробелами, но не больше size − 1 символов, и всегда ставит ноль. Перевод строки '\n' она оставляет в буфере — его убирают. Функция для всех программ курса дальше:
/* Прочитать строку в buf размера size, без '\n'.
Если строка длиннее буфера — сохранить начало, остаток выбросить.
false — ввод закончился. */
bool read_line(char buf[], int size)
{
if (fgets(buf, size, stdin) == NULL) {
return false;
}
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') {
buf[len - 1] = '\0';
} else {
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
}
}
return true;
}
Если читать одно слово всё же нужно через scanf, ширина ограничивается в формате: scanf("%7s", name) для массива из 8. Число в формате — на единицу меньше размера массива.
scanf("%d", &n) во вводе остаётся '\n' от нажатия Enter, и следующий fgets прочитает пустую строку. Перед fgets остаток строки выбрасывают (раздел 6, пункт 05) — или читают числа тоже строкой и переводят её в число (пункт 06).Строку обходят до нуля, а не до размера массива:
/* Длина строки — свой аналог strlen */
size_t my_strlen(const char *s)
{
size_t n = 0;
while (s[n] != '\0') {
n++;
}
return n;
}
/* Сколько раз в строке встречается символ c */
int count_char(const char *s, char c)
{
int count = 0;
for (const char *p = s; *p != '\0'; p++) { /* то же указателем */
if (*p == c) {
count++;
}
}
return count;
}
Параметр строки — const char *: функция получает адрес первого символа (раздел 10), const обещает не менять. Размер буфера функции, которая только читает строку, не нужен — конец отмечен нулём. Функции, которая пишет в строку, размер буфера нужен всегда.
strlen выполняет цикл до нуля — O(n). Поэтому for (int i = 0; i < strlen(s); i++) считает длину заново на каждой итерации — O(n²) вместо O(n). Длину вычисляют один раз до цикла.
<string.h> и <ctype.h>| Функция | Что делает | Ловушка |
|---|---|---|
strlen(s) | длина без нуля, тип size_t | O(n) — не вызывать в условии цикла |
strcmp(a, b) | < 0, если a раньше b по кодам символов; 0 — равны; > 0 — позже | a == b сравнивает адреса, а не строки; результат — не обязательно −1 и 1 |
strncmp(a, b, n) | сравнить не больше n символов | |
strcpy(dst, src) | копировать строку | не знает размера dst — переполнение; вместо неё snprintf |
strcat(dst, src) | дописать в конец | то же; вместо неё snprintf или контроль длины |
snprintf(dst, size, "%s", src) | записать в dst не больше size − 1 символов и ноль | возвращает длину, которая была бы записана: если ≥ size — строка обрезана |
strchr(s, c) | указатель на первый символ c или NULL | проверять на NULL |
strstr(s, sub) | указатель на первое вхождение подстроки или NULL | позиция — разность указателей p - s |
strcspn(s, "\n") | длина начала s без символов из второй строки | удобно убрать '\n': s[strcspn(s, "\n")] = '\0'; |
<ctype.h> — классификация символов: isdigit, isalpha, isalnum, isspace, isupper, islower и перевод регистра toupper, tolower. Аргумент — код символа как unsigned char: для байта кириллицы в UTF-8 (код больше 127) char отрицателен, и isalpha(c) с отрицательным аргументом — неопределённое поведение. Правило: isalpha((unsigned char)c).
/* Перевести строку в верхний регистр на месте (только латиница) */
void to_upper(char *s)
{
for (; *s != '\0'; s++) {
*s = (char)toupper((unsigned char)*s);
}
}
Слово — непрерывная последовательность непробельных символов. Начало слова — непробельный символ, перед которым пробел или начало строки. Подсчёт слов за один проход с флагом «внутри слова»:
/* Число слов, разделённых пробельными символами */
int count_words(const char *s)
{
int words = 0;
bool in_word = false;
for (; *s != '\0'; s++) {
if (isspace((unsigned char)*s)) {
in_word = false;
} else if (!in_word) {
in_word = true; /* начало нового слова */
words++;
}
}
return words;
}
/* "the quick brown" -> 3, " " -> 0, "" -> 0 */
Такой проход с состоянием — конечный автомат: в каждый момент программа «находится» в одном из состояний (в слове / между словами), и символ определяет переход. Так же разбирают числа в тексте, CSV-строки, команды. В стандартной библиотеке есть strtok, но она портит исходную строку (вписывает нули) и хранит скрытое состояние между вызовами — в курсе её не используем.
strtol и sscanfatoi("12abc") вернёт 12, а atoi("abc") — 0, и ошибку не отличить от нуля. Надёжно — strtol из <stdlib.h>: она сообщает, где закончилось число, и выставляет errno при переполнении:
/* Строка целиком — целое число в [min; max]? Результат — в *value */
bool parse_int(const char *s, long min, long max, long *value)
{
char *end = NULL;
errno = 0;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0' || errno == ERANGE || v < min || v > max) {
return false; /* нет цифр, мусор после числа, переполнение */
}
*value = v;
return true;
}
/* "42" -> 42; "-7" -> -7; "12abc", "", "abc", "99999999999999999999" -> false */
strtol(s, &end, 10): третий аргумент — основание системы счисления (2…36). В end записывается адрес первого непрочитанного символа — поэтому передаётся адрес указателя. Пробелы в начале strtol пропускает сама. long здесь — тип результата функции, а не выбор курса (раздел 3); границы min и max задача берёт в пределах int.
sscanf(s, формат, …) — scanf, который читает не с клавиатуры, а из строки: sscanf("12:45", "%d:%d", &h, &m) вернёт 2. Удобный способ: читать ввод строкой через read_line, а разбирать sscanf или parse_int — тогда остатков во вводе не бывает.
Строки фиксированной наибольшей длины хранят в двумерном массиве char, неизменяемые — массивом указателей на литералы:
char names[30][41]; /* 30 строк до 40 символов */
const char *days[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
printf("%s\n", days[2]); /* Wed */
Массив days заменяет switch из раздела 5: название дня — элемент массива по номеру. Сортировка строк — любая сортировка раздела 8, где сравнение — strcmp(a, b) > 0, а обмен строк двумерного массива — через временный буфер. В массиве указателей обмениваются только указатели — быстро и без копирования текста. strcmp сравнивает по кодам: все заглавные латинские буквы раньше строчных ("Zoe" < "adam").
Исходники курса и консоль MSYS2, Linux, веб — в кодировке UTF-8: латинская буква — 1 байт, русская — 2, иероглиф — 3, эмодзи — 4. Для C строка — байты, поэтому:
strlen("Привет") — 12, а не 6;s[0] — не буква «П», а первая половина её кода; переворот строки по байтам ломает русские буквы;toupper и isalpha работают только с латиницей.Число букв UTF-8 — число байт, которые не являются продолжением символа: у байтов-продолжений старшие биты 10, то есть (byte & 0xC0) == 0x80 (битовые операции — раздел 12). Полноценная работа с Юникодом — задача библиотек; в практических работах курса текст — латиница, а программы, которые принимают русский текст, обязаны хотя бы не ломать его.
printf(name) работает, пока в имени нет %. Ввод %s%s%s%n заставит printf читать и писать память по случайным адресам — это уязвимость форматной строки. Строка, пришедшая извне, выводится только так: printf("%s", name). gcc предупреждает об этом с ключом -Wformat-security (в Ubuntu он включён по умолчанию; в курсе его стоит добавить к ключам сборки): format not a string literal and no format arguments.char с '\0' в конце; под строку из N символов — N + 1 байтfgets с размером буфера; scanf("%s") без ширины и gets — переполнение буфераstrcmp, копируют с контролем размера (snprintf); == сравнивает адресаstrlen — O(n): длину считают один раз до циклаctype.h получают (unsigned char)cstrtol с проверкой конца и диапазона, а не atoi"%s"char как число, коды ASCII; раздел 8 — массив и его границы; раздел 10 — указатели, const char *, разность указателей. Нужен для: раздела 12 — биты байтов UTF-8; раздела 13 — строки в полях структур и строки текстового файла; раздела 19 — хеш-функция строки и словарь; раздела 20 — std::string.