Защитное программирование

Материал из Гуру — мира словарей и энциклопедий
Перейти к: навигация, поиск

Защитное программирование (defensive coding) — это стиль написания компьютерных программ, призванный сделать их более отказоустойчивыми в случае возникновения серьезных функциональных отклонений. Обычно подобное незапланированное поведение возникает из-за наличия багов в программе, но оно может быть обусловлено и совсем другими причинами: поврежденными данными, отказами аппаратного обеспечения, багами, которые возникают в программе в процессе ее доработки. Оказываясь в критической ситуации, код, написанный в защитном стиле, пытается принять максимально разумные меры с небольшим снижением производительности. Также такой код не должен допускать создания условий для возникновения новых ошибок.

История[править]

Впервые я столкнулся с термином «защитное программирование» в книге Кернигана и Ритчи (The C Programming Language, 1st Edition). После тщательных поисков мне не удалось найти более ранних упоминаний этого термина. Вероятно, он был придуман по аналогии с «безопасным вождением», о котором стали активно рассуждать в начале 1970-х, за несколько лет до появления книги Кернигана и Ритчи.

В предметном указателе к книге K&R указано две страницы, на которых употребляется этот термин. На стр. 53 он означает написание кода, не допускающего возникновения багов, а на стр. 56 этот термин понимается уже немного иначе: создание кода, снижающего вероятность возникновения багов при последующих изменениях кода в процессе его доработки. В любом случае с тех пор термин «защитное программирование» употреблялся во многих книгах. Обычно под ним понимается обеспечение работоспособности кода даже при наличии багов — например, в книге «The Pragmatic Programmer» Эндрю Ханта и Дэйва Томаса (где о «программировании в защитном стиле» рассказывается в главе «Pragmatic Paranoia»), а также в других источниках.

Различия в толковании[править]

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

Еще одна классическая и часто цитируемая статья, озаглавленная просто «Defensive Programming», имеет очень высокий рейтинг на сайте Codeproject.com. Это по-своему замечательная и содержательная статья, но она рассказывает не строго о защитном программировании, а, по признанию самого автора, «о методах, полезных при отлавливании программных ошибок». Как будет показано ниже, защитное программирование оказывает противоположный эффект — оно не отлавливает ошибки, а скорее скрывает их. Упомянутая статья с Codeproject.com затрагивает многие темы, и ее следовало бы переименовать, например, в «Good Coding Practices».

Сравнение обработки ошибок и защитного программирования

Многие разработчики нечетко представляют себе разницу между обработкой ошибок и защитным программированием. Постараюсь ее объяснить.

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

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

Итак, выбираемая нами стратегия — защитное программирование или явное добавление обработки ошибок — зависит от области применения конкретной программы. Подробнее мы поговорим об этом в разделе «Область применения».

Вторая проблема заключается в том, что возможны пограничные случаи, в которых возможность или невозможность возникновения определенных условий является спорной. Рассмотрим следующий набор сценариев, которые могут сложиться в программе, если она получит невалидные данные:
  1. программа принимает информацию прямо от пользователя, который может ввести невалидные данные;
  2. программа принимает данные из текстового файла, написанного человеком;
  3. программа принимает данные из XML-файла (сгенерированного автоматически или вручную);
  4. программа считывает файл с бинарными данными, созданный другой программой;
  5. программа считывает файл с бинарными данными, созданный ею же;
  6. программа считывает файл с бинарными данными, содержащий контрольную сумму для проверки наличия/отсутствия в нем повреждений;
  7. программа считывает временный бинарный файл, только что созданный ею же;
  8. программа считывает файл, созданный ею же и отображаемый в память;
  9. программа считывает информацию из локальной переменной (то есть из памяти), которую она только что записала.

В какой момент мы можем быть уверены, что данные не могут оказаться невалидными? Я считаю, что совершенно невозможен случай, в котором файл с невалидными данными продолжает генерировать верную контрольную сумму (см. сценарий 6). Тем не менее, если данные обладают повышенной критичностью с точки зрения безопасности, необходимо учесть и вероятность того, что файл был специально подправлен для получения «верной» контрольной суммы. В таком случае придется использовать криптографическую контрольную сумму, например SHA1.

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

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

Итак, не всегда ясно, в каких случаях вам понадобится специальный код для обработки ошибок, а в каких будет достаточно защитного программирования.

Пример

Классический пример защитного программирования можно найти практически в любой программе, когда-либо написанной на C. Речь о случаях, когда условие завершения пишется не как тест на неравенство ( < ), а как тест на неэквивалентность (!=). Например, типичный цикл пишется так:

  size_t len = strlen(str);
  for (i = 0; i < len; ++i)
      result += evaluate(str[i]);
а не так:
  size_t len = strlen(str);
  for (i = 0; i != len; ++i)
      result += evaluate(str[i]);

Очевидно, оба фрагмента должны работать аналогично, поскольку переменная 'i' может только увеличиваться и ни при каких условиях не может стать неравной 'len'. Почему же условия завершения цикла всегда пишутся только по первому образцу, а не по второму?

Во-первых, последствия возникновения «невозможного» условия очень пагубны и, вероятно, могут привести к всевозможным неприятным последствиям в готовой программе — например, к возникновению бесконечного цикла или нарушению доступа к памяти. Такое «невозможное» условие вполне может возникнуть в некоторых ситуациях: – плохое оборудование или залетный фотон гамма-излучения могут привести к тому, что один из битов 'i' случайным образом изменит состояние; – другой ошибочный процесс (в системе без аппаратной защиты памяти) или поток изменяет не принадлежащий ему фрагмент памяти; – ошибочный вышестоящий код (то есть код операционной системы или драйвера устройства) изменяет память; – функция 'evaluate' содержит вредоносный указатель, изменяющий значение 'i'; – функция 'evaluate' повреждает указатель фреймового стека, и переменная 'i' оказывается в какой-то случайной точке стека; – при последующих изменениях кода возникают баги, например:

      for (i = 0; i != len; ++i)
      {
          while (!isprint(str[i]))  // патологическое изменение кода, при котором 'i' может никогда не оказаться равным 'len'
              ++i;
          result += evaluate(str[i]);
      }

Разумеется, несколько последних случаев, вызываемые программными ошибками, встречаются наиболее часто. Именно поэтому защитное программирование зачастую ассоциируется с защитой от багов.

Культура C[править]

Есть еще два аспекта языка C, определяющих, как и когда в нем используется защитное программирование. Я имею в виду, во-первых, акцент C на эффективности кода и, во-вторых, применяемые здесь подходы к обработке ошибок.

Начнем с эффективности. Одна из базовых предпосылок работы с C заключается в том, что программист знает, что делает. Язык не защищает нас от возможных ошибок, тогда как другие хотя бы пытаются это делать. Например, на C при записи данных легко забросить их за пределы массива — но если при доступе к массиву применяется проверка границ (выполняемая компилятором), то программа будет работать медленнее даже при совершенно безопасном коде.

Поскольку в C делается акцент на эффективность, защитное программирование применяется лишь в тех случаях, когда оно не оказывает негативного влияния на производительность или если такое влияние минимально. Типичный пример приведен выше, поскольку оператор «меньше» обычно не уступает по скорости «не равно».

Второй аспект — это организация обработки ошибок в C. Обычно ошибки в C обрабатываются с использованием возвращаемых значений ошибок. Обработка ошибок зачастую играет в коде C определяющую роль, поэтому потенциальные условия возникновения ошибок игнорируются, если они представляются маловероятными. Например, никто и не подумает проверять возвращаемое значение ошибки от printf(). На самом деле условия ошибок порой игнорируются и тогда, когда этого делать не следует, но это уже тема для другой дискуссии.

Итак, если наличие «маловероятных» ошибок обычно не проверяется, целесообразно обрабатывать «невозможные» условия, поскольку, если возникнут подобные маловероятные ошибки, они значительно осложнят весь процесс обработки. Разумеется, в языках с обработкой исключений многие подобные проблемы легко снимаются путем выдачи программного исключения.

Область применения[править]

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

В любом случае даже если вы считаете условие невозможным, то будет разумно застраховаться от него при помощи защитного хода. Многие функции просто возвращаются, если им неожиданно был передан NULL. Опять же такой случай отличается от обработки ошибок, поскольку значение ошибки не генерируется.

Поэтому, обсуждая защитное программирование, всегда следует учитывать область применения рассматриваемого кода. Как раз этот момент не учтен в статье Википедии на данную тему.

Симптомы[править]

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

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

Проблемы защитного программирования[править]

Итак, теперь мы вполне четко понимаем основную проблему защитного программирования. Защитное программирование скрывает от нас наличие багов

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

Гораздо хуже то, что защитное программирование скрывает ошибки и на этапах разработки и тестирования. Думаю, никто не считает, что это хорошо. Альтернатива — это использование подхода, иногда именуемого «агрессивное программирование» или «принцип быстрого отказа» (fail fast). Такие подходы нацелены как раз на быстрое проявление ошибок, а не на замалчивание.

Я применяю защитное программирование так, что в готовых сборках остается возможность для обработки неожиданных или условно невозможных ситуаций. Но я также добавляю контрольные операторы для проверки невозможных ситуаций — такие операторы нужны, чтобы в программу не проникли баги. Кроме того, во время тестирования я работаю преимущественно с отладочной сборкой (где уже используются такие утверждения), проверяя готовый продукт уже при окончательном приемочном тестировании. Для самых критических моментов я также явно добавляю код для обработки ошибок, поскольку в готовых сборках контрольные операторы удаляются.

Упражнение[править]

Наконец, предлагаю вам пищу для размышлений. В стандартной библиотеке C есть функция, принимающая строку цифр и возвращающая целое число. Эта функция называется atoi.

Если вы не знакомы с atoi(), уточню, что она не возвращает никаких кодов ошибок, но останавливается, как только ей встречается первый же неожиданный символ. Например, atoi("two") просто возвращает нуль.

Является ли поведение atoi() примером защитного программирования? Почему?

Как можно улучшить эту функцию?