Различия

Здесь показаны различия между двумя версиями данной страницы.

Ссылка на это сравнение

articles:c-tricks-8 [2017/09/05 02:55] (текущий)
Строка 1: Строка 1:
 +====== c-tricks-8 ======
 +<​sub>​{{c-tricks-8.odt|Original file}}</​sub>​
 +
 +====== сишные трюки\\ (8й выпуск) ======
 +
 +===== проверка выделения памяти —\\ ошибка или гениальная задумка =====
 +
 +Начнем издалека. Программирование — это своего рода дзен, это поединок кода с мыслю, а поединок это уже кун фу, а кун фу это когда ты "​сначала ты не знаешь,​ что нельзя делать то-то, потом знаешь,​ что нельзя делать то-то, потом ты понимаешь,​ что иногда таки можно делать то-то, ну а потом ты понимаешь,​ что помимо того-то существует еще шестьдесят девять способов добиться желаемого,​ и все из них практически равноправны,​ но когда тебя спрашивают "​как мне добиться желаемого",​ ты быстро перебираешь в уме эти шестьдесят ​ девять способов,​ прикидываешь то общее, что в них есть, вздыхаешь и говоришь:​ "​вообще-то,​ главное - гармония"​ и на вопрос обиженных учеников:​ "а как ее добиться?",​ ты говоришь:​ "​никогда не делайте то-то"​.
 +
 +Или, та же истина,​ только в чуть-чуть более длинной интерпретации:​ есть несколько шагов обучения воинскому рукопашному искусству:​
 +
 +  - новичка обучают узкому списку приемов. на каждую ситуацию ему говорят — делай так-то. и новичок оттачивает эти приемы до филигранной точности;​
 +  - ученику показывают,​ как известные ему приемы можно объединять,​ переходить от одной технике к другой и получать новые приемы;​
 +  - мастер о следовании конкретным приемам не заботится. он в каждый момент анализирует ситуацию,​ видит множество путей ее решения (и все они правильные),​ и сосредотачивает свои усилия на максимальном достижении выбранной цели;
 +  - а новичок,​ глядя на мастера,​ не может взять в толк, почему тот не использует ни один из известных приемов.
 +Применительно к программированию это означает,​ что грань между ошибкой и задумкой настолько тонка, что порой совсем незаметна. Возьмем классическую ситуацию с проверкой успешности выделения памяти. Можно ли считать следующий код правильным?​ (отсутствие проверки на валидность указателя *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. Это правило номер один, которое каждый новичок должен знать назубок и несоблюдение которого приводит к утечкам памяти. Однако,​ освобождать память бывает не всегда удобно,​ а порой даже очень затруднительно. А что если… не освобождать! При всей внешней бредовости это весьма здравая идея. Действительно частые выделения/​освобождения памяти это тормоза и рост фрагментации кучи. Лучше выделять память с запасом,​ используя ненужные блоки повторно _без_ их освобождения. Это раз.
 +
 +Если освобождение памяти сопряжено с некоторыми трудностями (например,​ мы хотим чтобы функция возвращала указатель на выделенный ею блок памяти,​ но не осмеливалась требовать от материнской функции его освобождения),​ прежде чем ломать голову над тем "​как же это, блин, закодировать",​ следует расслабиться,​ слегонца покурить и посчитать максимально возможный размер потерь в случае умышленного не освобождения памяти. Мы же ведь не в каменном веке живем и вполне можем позволить себе растранжирь несколько десятков мегабайт памяти,​ если это упростит кодирование (конечно,​ подсчет потерь должен делаться не наобум,​ иначе десятки могут на деле превратится в сотни, заставляя систему скрипеть винтом).
 +
 +===== заключение =====
 +
 +Прежде чем воспользоваться приведенными здесь советами,​ перечитайте классику кун фу еще раз. Чтобы отступать от правил,​ сначала нужно научиться их соблюдать! И каждое отступление должно иметь под собой твердое основание и мотивацию. Отмазка в стиле: я не освобождаю память,​ не проверяю валидности указателей,​ потому что Настоящий Мастера этого не выполняют совершенно не катит, потому что Мастера просчитывают те ситуации,​ существование которых неведомо новичку. Но даже у Мастеров доминирует мотивация:​ рискнем,​ авось пронесет!
 +
 +