Как стать автором
Обновить

Немного о строках в Си, или несколько вариантов оптимизировать неоптимизируемое

Время на прочтение9 мин
Количество просмотров189K
Хабра, привет!

Не так давно у со мной произошел довольно-таки интересный инцидент, в котором был замешан один из преподавателей одного колледжа информатики.

Разговор о программировании под Linux медленно перешел к тому, что этот человек стал утверждать, что сложность системного программирования на самом деле сильно преувеличена. Что язык Си прост как спичка, собственно как и ядро Linux (с его слов).

У меня был с собой ноутбук с Linux, на котором присутствовал джентльменский набор утилит для разработки на языке Си (gcc, vim, make, valgrind, gdb). Я уже не помню, какую цель мы тогда перед собой поставили, но через пару минут мой оппонент оказался за этим ноутбуком, полностью готовый решать задачу.

И буквально на первых же строках он допустил серьезную ошибку при аллоцировании памяти под… строку.

char *str = (char *)malloc(sizeof(char) * strlen(buffer));

buffer — стековая переменная, в которую заносились данные с клавиатуры.

Я думаю, определенно найдутся люди, которые спросят: «Разве что-то тут может быть не так?».
Поверьте, может.

А что именно — читайте по катом.

Немного теории — своеобразный ЛикБез.


Если знаете — листайте до следующего хэдера.

Строка в C — это массив символов, который по-хорошему всегда должен заканчиваться '\0' — символом конца строки. Строки на стеке (статичные) объявляются вот так:

char str[n] = { 0 }; 

n — размер массива символов, то же, что и длина строки.

Присваивание { 0 } — «зануление» строки (опционально, объявлять можно и без него). Результат такой же, как у выполнения функций memset(str, 0, sizeof(str)) и bzero(str, sizeof(str)). Используется, чтобы в неинициализированных переменных не валялся мусор.

Так же на стеке можно сразу проинициализировать строку:

char buf[BUFSIZE] = "default buffer text\n";

Помимо этого строку можно объявить указателем и выделить под нее память на куче (heap):

char *str = malloc(size);

size — количество байт, которые мы выделяем под строку. Такие строки называются динамическими (вследствие того, что нужный размер вычисляется динамически + выделенный размер памяти можно в любой момент увеличить с помощью функции realloc() ).

В случае со стековой переменной, для определения размера массива я использовал обозначение n, в случае с переменной на куче — я использовал обозначение size. И это прекрасно отражает истинную суть отличия объявления на стеке от объявление с аллоцированием памяти на куче, ведь n как правило используется тогда, когда говорят о количестве элементов. А size — это уже совсем другая история…

Думаю. пока хватит. Идем дальше.

Нам поможет valgrind


В своей предыдущей статье я также упоминал о нем. Valgrind (раз — вики-статья, два — небольшой how-to) — очень полезная программа, которая помогает программисту отслеживать утечки памяти и ошибки контекста — как раз те вещи, которые чаще всего всплывают при работе со строками.

Давайте рассмотрим небольшой листинг, в котором реализовано что-то похожее на упомянутую мной программу, и прогоним ее через valgrind:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define HELLO_STRING "Hello, Habr!\n"

void main() {
  char *str = malloc(sizeof(char) * strlen(HELLO_STRING));
  strcpy(str, HELLO_STRING);
  printf("->\t%s", str);
  free(str);
}

И, собственно, результат работы программы:

[indever@localhost public]$ gcc main.c 
[indever@localhost public]$ ./a.out 
->	Hello, Habr!

Пока ничего необычного. А теперь давайте запустим эту программу с valgrind!

[indever@localhost public]$ valgrind --tool=memcheck ./a.out 
==3892== Memcheck, a memory error detector
==3892== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==3892== Using Valgrind-3.12.0 and LibVEX; rerun with -h for copyright info
==3892== Command: ./a.out
==3892== 
==3892== Invalid write of size 2
==3892==    at 0x4005B4: main (in /home/indever/prg/C/public/a.out)
==3892==  Address 0x520004c is 12 bytes inside a block of size 13 alloc'd
==3892==    at 0x4C2DB9D: malloc (vg_replace_malloc.c:299)
==3892==    by 0x400597: main (in /home/indever/prg/C/public/a.out)
==3892== 
==3892== Invalid read of size 1
==3892==    at 0x4C30BC4: strlen (vg_replace_strmem.c:454)
==3892==    by 0x4E89AD0: vfprintf (in /usr/lib64/libc-2.24.so)
==3892==    by 0x4E90718: printf (in /usr/lib64/libc-2.24.so)
==3892==    by 0x4005CF: main (in /home/indever/prg/C/public/a.out)
==3892==  Address 0x520004d is 0 bytes after a block of size 13 alloc'd
==3892==    at 0x4C2DB9D: malloc (vg_replace_malloc.c:299)
==3892==    by 0x400597: main (in /home/indever/prg/C/public/a.out)
==3892== 
->	Hello, Habr!
==3892== 
==3892== HEAP SUMMARY:
==3892==     in use at exit: 0 bytes in 0 blocks
==3892==   total heap usage: 2 allocs, 2 frees, 1,037 bytes allocated
==3892== 
==3892== All heap blocks were freed -- no leaks are possible
==3892== 
==3892== For counts of detected and suppressed errors, rerun with: -v
==3892== ERROR SUMMARY: 3 errors from 2 contexts (suppressed: 0 from 0)

==3892== All heap blocks were freed — no leaks are possible — утечек нет, и это радует. Но стоит опустить глаза чуть пониже (хотя, хочу заметить, это лишь итог, основная информация немного в другом месте):

==3892== ERROR SUMMARY: 3 errors from 2 contexts (suppressed: 0 from 0)
3 ошибки. В 2х контекстах. В такой простой программе. Как!?

Да очень просто. Весь «прикол» в том, что функция strlen не учитывает символ конца строки — '\0'. Даже если его явно указать во входящей строке (#define HELLO_STRING «Hello, Habr!\n\0»), он будет проигнорирован.

Чуть выше результата исполнения программы, строки -> Hello, Habr! есть подробный отчет, что и где не понравилось нашему драгоценному valgrind. Предлагаю самостоятельно посмотреть эти строчки и сделать выводы.

Собственно, правильная версия программы будет выглядеть так:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define HELLO_STRING "Hello, Habr!\n"

void main() {
  char *str = malloc(sizeof(char) * (strlen(HELLO_STRING) + 1));
  strcpy(str, HELLO_STRING);
  printf("->\t%s", str);
  free(str);
}

Пропускаем через valgrind:

[indever@localhost public]$ valgrind --tool=memcheck ./a.out 
->	Hello, Habr!
==3435== 
==3435== HEAP SUMMARY:
==3435==     in use at exit: 0 bytes in 0 blocks
==3435==   total heap usage: 2 allocs, 2 frees, 1,038 bytes allocated
==3435== 
==3435== All heap blocks were freed -- no leaks are possible
==3435== 
==3435== For counts of detected and suppressed errors, rerun with: -v
==3435== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Отлично. Ошибок нет, +1 байт выделяемой памяти помог решить проблему.

Что интересно, в большинстве случаев и первая и вторая программа будут работать одинаково, но если память, выделенная под строку, в которую не влез символ окончания, не была занулена, то функция printf(), при выводе такой строки, выведет и весь мусор после этой строки — будет выведено все, пока на пути printf() не встанет символ окончания строки.

Однако, знаете, (strlen(str) + 1) — такое себе решение. Перед нами встают 2 проблемы:

  1. А если нам надо выделить память под формируемую с помощью, например, s(n)printf(..) строку? Аргументы мы не поддерживаем.
  2. Внешний вид. Строка с объявлением переменной выглядит просто ужасно. Некоторые ребята к malloc еще и (char *) умудряются прикручивать, будто под плюсами пишут. В программе где регулярно требуется обрабатывать строки есть смысл найти более изящное решение.

Давайте придумаем такое решение, которое удовлетворит и нас, и valgrind.

snprintf()


int snprintf(char *str, size_t size, const char *format, ...); — функция — расширение sprintf, которая форматирует строку и записывает ее по указателю, переданному в качестве первого аргумента. От sprintf() она отличается тем, что в str не будет записано байт больше, чем указано в size.

Функция имеет одну интересную особенность — она в любом случае возвращает размер формируемой строки (без учета символа конца строки). Если строка пустая, то возвращается 0.

Одна из описанных мною проблем использования strlen связана с функциями sprintf() и snprintf(). Предположим, что нам надо что-то записать в строку str. Конечная строка содержит значения других переменных. Наша запись должна быть примерно такой:

char * str = /* тут аллоцируем память */;
sprintf(str, "Hello, %s\n", "Habr!");

Встает вопрос: как определить, сколько памяти надо выделить под строку str?

char * str = malloc(sizeof(char) * (strlen(str, "Hello, %s\n", "Habr!") + 1));
— не прокатит. Прототип функции strlen() выглядит так:

#include <string.h>
size_t strlen(const char *s);

const char *s не подразумевает, что передаваемая в s строка может быть строкой формата с переменным количеством аргументов.

Тут нам поможет то полезное свойство функции snprintf(), о котором я говорил выше. Давайте посмотрим на код следующей программы:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void main() {
/* Т.к. snprintf() не учитывает символ конца строки, прибавляем его размер к результату */
  size_t needed_mem = snprintf(NULL, 0, "Hello, %s!\n", "Habr") + sizeof('\0'); 
  char *str = malloc(needed_mem);
  snprintf(str, needed_mem, "Hello, %s!\n", "Habr");
  printf("->\t%s", str);
  free(str);
}

Запускаем программу в valgrind:

[indever@localhost public]$ valgrind --tool=memcheck ./a.out 
->	Hello, Habr!
==4132== 
==4132== HEAP SUMMARY:
==4132==     in use at exit: 0 bytes in 0 blocks
==4132==   total heap usage: 2 allocs, 2 frees, 1,041 bytes allocated
==4132== 
==4132== All heap blocks were freed -- no leaks are possible
==4132== 
==4132== For counts of detected and suppressed errors, rerun with: -v
==4132== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
[indever@localhost public]$ 

Отлично. Поддержка аргументов у нас есть. Благодаря тому, что мы в качестве второго аргумента в функцию snprintf() передаем ноль, запись по нулевому указателю никогда не приведет к Seagfault. Однако, несмотря на это функция все равно вернет необходимый под строку размер.

Но с другой стороны, нам пришлось завести дополнительную переменную, да и конструкция

size_t needed_mem = snprintf(NULL, 0, "Hello, %s!\n", "Habr") + sizeof('\0');

выглядит еще хуже, чем в случае с strlen().

Вообще, + sizeof('\0') можно убрать, если в конце строки формата явно указать '\0' (size_t needed_mem = snprintf(NULL, 0, «Hello, %s!\n\0», «Habr»);), но это возможно отнюдь не всегда (в зависимости от механизма обработки строк мы можем выделить лишний байт).

Надо что-то сделать. Я немного подумал и решил, что сейчас настал час воззвать к мудрости древних. Опишем макрофункцию, которая будет вызывать snprintf() с нулевым указателем в качестве первого аргумента, и нулем, в качестве второго. Да и про конец строки не забудем!

#define strsize(args...) snprintf(NULL, 0, args) + sizeof('\0')

Да, возможно, для кого-то будет новостью, но макросы в си поддерживают переменное количество аргументов, и троеточие говорит препроцессору о том, что указанному аргументу макрофункции (в нашем случае это args) соответствует несколько реальных аргументов.

Проверим наше решение на практике:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define strsize(args...) snprintf(NULL, 0, args) + sizeof('\0')

void main() {
  char *str = malloc(strsize("Hello, %s\n", "Habr!"));
  sprintf(str, "Hello, %s\n", "Habr!");
  printf("->\t%s", str);
  free(str);
}

Запускаем с valgrund:

[indever@localhost public]$ valgrind --tool=memcheck ./a.out 
->	Hello, Habr!
==6432== 
==6432== HEAP SUMMARY:
==6432==     in use at exit: 0 bytes in 0 blocks
==6432==   total heap usage: 2 allocs, 2 frees, 1,041 bytes allocated
==6432== 
==6432== All heap blocks were freed -- no leaks are possible
==6432== 
==6432== For counts of detected and suppressed errors, rerun with: -v
==6432== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Да, ошибок нет. Все корректно. И valgrind доволен, и программист наконец может пойти поспать.

Но, напоследок, скажу еще кое-что. В случае, если нам надо выделить память под какую-либо строку (даже с аргументами) есть уже полностью рабочее готовое решение.

Речь идет о функции asprintf:

#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include <stdio.h>

int asprintf(char **strp, const char *fmt, ...);

В качестве первого аргумента она принимает указатель на строку (**strp) и аллоцирует память по разыменованному указателю.

Наша программа, написанная с использованием asprintf() будет выглядеть так:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void main() {
  char *str;
  asprintf(&str, "Hello, %s!\n", "Habr");
  printf("->\t%s", str);
  free(str);
}

И, собственно, в valgrind:

[indever@localhost public]$ valgrind --tool=memcheck ./a.out 
->	Hello, Habr!
==6674== 
==6674== HEAP SUMMARY:
==6674==     in use at exit: 0 bytes in 0 blocks
==6674==   total heap usage: 3 allocs, 3 frees, 1,138 bytes allocated
==6674== 
==6674== All heap blocks were freed -- no leaks are possible
==6674== 
==6674== For counts of detected and suppressed errors, rerun with: -v
==6674== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Все отлично, но, как видите, памяти всего было выделено больше, да и alloc'ов теперь три, а не два. На слабых встраиваемых системах использование это функции нежелательно.
К тому же, если мы напишем в консоли man asprintf, то увидим:

CONFORMING TO
       These functions are GNU extensions, not in C or POSIX.  They are also available under *BSD.  The FreeBSD implementation sets strp to  NULL on error.


Отсюда ясно, что данная функция доступна только в исходниках GNU.

Заключение


В заключение я хочу сказать, что работа со строками в C — это очень сложная тема, которая имеет ряд нюансов. Например, для написания «безопасного» кода при динамическом выделении памяти рекомендуется все же использовать функцию calloc() вместо malloc() — calloc забивает выделяемую память нулями. Ну или после выделения памяти использовать функцию memset(). Иначе мусор, который изначально лежал на выделяемом участке памяти, может вызвать вопросы при дебаге, а иногда и при работе со строкой.

Больше половины моих знакомых си-программистов (большинство из них — начинающие), решивших по моей просьбе задачу с выделением памяти под строки, сделали это так, что в конечном итоге это привело к ошибкам контекста. В одном случае — даже к утечке памяти (ну, забыл человек сделать free(str), с кем не бывает). Собственно говоря, это и сподвигло меня на создание сего творения, которое вы только что прочитали.

Я надеюсь, кому-то эта статья будет полезной. К чему я это все городил — никакой язык не бывает прост. Везде есть свои тонкости. И чем больше тонкостей языка вы знаете, тем лучше ваш код.

Я верю, что после прочтения этой статьи ваш код станет чуточку лучше :)
Удачи, Хабр!
Теги:
Хабы:
+36
Комментарии121

Публикации

Истории

Работа

Программист С
48 вакансий
QT разработчик
7 вакансий
Программист C++
129 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн