c-tricks-8

сишные трюки\\ (8й выпуск)

Начнем издалека. Программирование — это своего рода дзен, это поединок кода с мыслю, а поединок это уже кун фу, а кун фу это когда ты «сначала ты не знаешь, что нельзя делать то-то, потом знаешь, что нельзя делать то-то, потом ты понимаешь, что иногда таки можно делать то-то, ну а потом ты понимаешь, что помимо того-то существует еще шестьдесят девять способов добиться желаемого, и все из них практически равноправны, но когда тебя спрашивают «как мне добиться желаемого», ты быстро перебираешь в уме эти шестьдесят девять способов, прикидываешь то общее, что в них есть, вздыхаешь и говоришь: «вообще-то, главное - гармония» и на вопрос обиженных учеников: «а как ее добиться?», ты говоришь: «никогда не делайте то-то».

Или, та же истина, только в чуть-чуть более длинной интерпретации: есть несколько шагов обучения воинскому рукопашному искусству:

  1. новичка обучают узкому списку приемов. на каждую ситуацию ему говорят — делай так-то. и новичок оттачивает эти приемы до филигранной точности;
  2. ученику показывают, как известные ему приемы можно объединять, переходить от одной технике к другой и получать новые приемы;
  3. мастер о следовании конкретным приемам не заботится. он в каждый момент анализирует ситуацию, видит множество путей ее решения (и все они правильные), и сосредотачивает свои усилия на максимальном достижении выбранной цели;
  4. а новичок, глядя на мастера, не может взять в толк, почему тот не использует ни один из известных приемов.

Применительно к программированию это означает, что грань между ошибкой и задумкой настолько тонка, что порой совсем незаметна. Возьмем классическую ситуацию с проверкой успешности выделения памяти. Можно ли считать следующий код правильным? (отсутствие проверки на валидность указателя *s не принимается в расчет).

zen(char *s)

{

char *p = malloc(strlen(s)+1));

strcpy(p,s);

free(p);

return 0;

}

Листинг 1 правильный неправильный код

«Да это же грубейшая ошибка!» — скажет начинающий — «где гарантия, что malloc выделит память?!» И по своему он будет прав, ведь такой гарантии у нас нет и более опытные товарищи явно посоветуют воткнуть «if». И они тоже по своему будут правы. Но только изначальный вариант окажется самым оптимальным компромиссом среди всех возможных решений.

Программирование — это в первую очередь учет рисков. Есть риск, что память не будут выдела и эту ситуацию надо предусмотреть и обработать заранее. Только как мы ее можем обработать? И в каких ситуациях malloc может не выделить память? Чем нам реально поможет дополнительный «if»?! Если памяти нет, то нет никаких гарантий, что удастся сделать хоть что-то, даже вывести примитивный диалог, не говоря уже о том, чтобы корректно завершить работу, сохранив все данные.

Самое главное, что операционная система совместно с процессором отслеживают попытки обращения к нулевому указателю (а точнее даже первым 64 Кбайтам адресного пространства), возбуждая исключение, которое мы можем словить и обработать. При отсутствии обработчика на экране появляется знаменитый диалог с сообщением о критической ошибке. Но это всяко лучше, чем «if (!p) returnERROR;», поскольку если вызывающая функция забудет о проверке на ERROR, программа продолжит свою работу, но вряд ли эта работа будет стабильной. Последуют либо глюки, либо падения в весьма отдаленных от функции zen местах и даже имея на руках дамп памяти (или отчет «Доктора Ватсона») можно угробить тучу времени на выяснение истинной причины аварии.

Это вовсе не призыв к отказу от проверок на корректность выделения памяти. Это просто констатация факта, что если память закончилась, то ситуация опаньки и _проверка_ ее не исправляет, а только усугубляет. Если мы действительно хотим принять такой фактор риска во внимание, необходимо предусмотреть _обработку_ ситуации (выделение памяти из стека или секции данных вместо кучи, резервирование памяти как НЗ на ранних стадиях запуска программы, освобождение ненужной памяти и т.д.). Но такой обработчик являет собой сложное инженерное сооружение, но малоэффективное в случаях с «утеканием» памяти в соседней программе. Да, в _своих_ функциях, мы можем использовать и стек вместо кучи и зарезервированную память, но системным и библиотечным процедурам этого не объяснишь и даже освободив все ненужное, мы не застрахованы, что соседняя программа его не съест.

Вывод: обрабатывать ситуацию с нехваткой памятью следует только тогда, когда это действительно критично и подобная обработка усложняет и утяжеляет программу на пару порядков. В остальных случаях — лучше рискнуть.

Должна ли функция проверять корректность переданных ей аргументов? Кое-кто скажет: должна. Кое-кто: это от спецификаций зависит. В некоторых случаях все проверки можно переложить на материнскую функцию, в некоторых ‑ нет. В частности, все ядерные native-API функции и драйвера крайне осторожно относятся к передаче аргументов из прикладного адресного пространства, совершая множество «телодвижений». Иначе и быть не может! В противном случае, передав некорректный указатель, пользователь мог бы нанести ядру серьезные увечья, что недопустимо!

А вот в рамках пользовательского пространства, проверка аргументов материнской функцией более политически корректна, поскольку, возможности дочерней функции в концептуальном плане весьма невелики. Если материнская функция при определенных обстоятельствах может передать невалидный указатель, то с таким же успехом она может проигнорировать ошибочный код завершения дочерней функции! Причем «валидный» и «ненулевой» указатель это совсем не одно и тоже! Да, мы можем легко выявить нулевой указатель, но только толку с того… Указатель может и не равняться нулю, но указывать в недоступную область памяти. Обычно это происходит когда нулевое значение, возращенное malloc, складывается с некоторым индексом и передается дочерней функции.

Если индекс меньше 10000h, то операционная система отловит такую ситуацию и выбросит исключение, а если нет? Вообще-то, существует целый легион API-функций типа IsBadReadPtr, IsBadWritePtr, IsBadStringPtr, позволяющих проверить: если у нас правда доступа к данной ячейке (ячейкам) памяти или их нема? Некоторые программисты их старательно используют и пихают во все функции, забывая о том, что: а) исключение останавливает программу, явно сигнализируя об ошибке, а обработка ошибки в стиле «if (IsBadStringPtr(s)) return ERROR» эту самую ошибку _подавляет_ постулируя, что материнская функция знает, что делать; б) указатель может принадлежать чужой области памяти, наличие прав доступа к которой ничуть не смягчает последствия их реализации.

Вывод: мы либо доверяем материнской функции, либо нет. Если мы ей доверяем, то необходимость в проверках отпадает, если же нет… тогда остается только вешаться, поскольку для проверки валиндных указателей необходима теговая архитектура, сопоставляющая с каждой ячейкой памяти (группой ячеек) поле, указывающее кто ей владеет. Поскольку, аппаратной поддержки со стороны x86 процессоров нет и не будет, для решения проблемы необходимо реализовать виртуальную машину. Тогда и только тогда дочерняя функция сможет осуществить проверку валидности переданных ей указателей.

Каждому malloc должен соответствовать свой free. Это правило номер один, которое каждый новичок должен знать назубок и несоблюдение которого приводит к утечкам памяти. Однако, освобождать память бывает не всегда удобно, а порой даже очень затруднительно. А что если… не освобождать! При всей внешней бредовости это весьма здравая идея. Действительно частые выделения/освобождения памяти это тормоза и рост фрагментации кучи. Лучше выделять память с запасом, используя ненужные блоки повторно _без_ их освобождения. Это раз.

Если освобождение памяти сопряжено с некоторыми трудностями (например, мы хотим чтобы функция возвращала указатель на выделенный ею блок памяти, но не осмеливалась требовать от материнской функции его освобождения), прежде чем ломать голову над тем «как же это, блин, закодировать», следует расслабиться, слегонца покурить и посчитать максимально возможный размер потерь в случае умышленного не освобождения памяти. Мы же ведь не в каменном веке живем и вполне можем позволить себе растранжирь несколько десятков мегабайт памяти, если это упростит кодирование (конечно, подсчет потерь должен делаться не наобум, иначе десятки могут на деле превратится в сотни, заставляя систему скрипеть винтом).

Прежде чем воспользоваться приведенными здесь советами, перечитайте классику кун фу еще раз. Чтобы отступать от правил, сначала нужно научиться их соблюдать! И каждое отступление должно иметь под собой твердое основание и мотивацию. Отмазка в стиле: я не освобождаю память, не проверяю валидности указателей, потому что Настоящий Мастера этого не выполняют совершенно не катит, потому что Мастера просчитывают те ситуации, существование которых неведомо новичку. Но даже у Мастеров доминирует мотивация: рискнем, авось пронесет!