Раздел 11 — Строки в C

Текст как массив символов с нулём в конце — и почему из-за этого нуля взламывают программы

Прогресс курса Раздел 11 из 28

Что вы освоите в этом разделе

3 академических часа теории и 2 часа практики: практическая работа №9 «Работа со строками» — на портале.
01

Строка — массив 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 символов
02

Ввод строки и переполнение буфера

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

Обход строки

Строку обходят до нуля, а не до размера массива:

/* Длина строки — свой аналог 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). Длину вычисляют один раз до цикла.

04

Функции <string.h> и <ctype.h>

ФункцияЧто делаетЛовушка
strlen(s)длина без нуля, тип size_tO(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);
    }
}
05

Разбор строки на слова

Слово — непрерывная последовательность непробельных символов. Начало слова — непробельный символ, перед которым пробел или начало строки. Подсчёт слов за один проход с флагом «внутри слова»:

/* Число слов, разделённых пробельными символами */
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, но она портит исходную строку (вписывает нули) и хранит скрытое состояние между вызовами — в курсе её не используем.

06

Текст в число: strtol и sscanf

atoi("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 — тогда остатков во вводе не бывает.

07

Массив строк и сортировка строк

Строки фиксированной наибольшей длины хранят в двумерном массиве 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").

08

Кириллица, UTF-8 и форматная строка

Исходники курса и консоль MSYS2, Linux, веб — в кодировке UTF-8: латинская буква — 1 байт, русская — 2, иероглиф — 3, эмодзи — 4. Для C строка — байты, поэтому:

Число букв 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)c
  • Текст в число — strtol с проверкой конца и диапазона, а не atoi
  • UTF-8: русская буква — 2 байта; строка пользователя выводится через "%s"
Связи раздела. Опирается на раздел 3 — char как число, коды ASCII; раздел 8 — массив и его границы; раздел 10 — указатели, const char *, разность указателей. Нужен для: раздела 12 — биты байтов UTF-8; раздела 13 — строки в полях структур и строки текстового файла; раздела 19 — хеш-функция строки и словарь; раздела 20 — std::string.
Практическая работа №9 и тест по разделу 11 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 10: Указатели Все разделы Раздел 12: Битовые операции и множества