среда, 2 октября 2013 г.

[prog.thoughts] Мое мнение о defensive programming

Боюсь, что моя вчерашняя реплика о защищенном программировании могла быть понята не правильно или даже не понята вовсе. Во-первых, из-за того, что defensive programming -- это весьма древнее по современным меркам понятие, которого многие молодые программисты могли даже не слышать. Во-вторых, просмотрев несколько статей на тему defensive programming я увидел, что их авторы, мягко говоря, путают теплое с мягким, приписывают defensive programming выдуманные ими самими проблемы, а потом доблестно их решают и признают defensive programming несостоятельным/вредным. В данной заметке я выскажу единственное верное мнение попробую объяснить свое понимание защищенного программирования.

О защищенном программировании я узнал в 90-м году, причем даже не применительно к языку C (к которому любят привязывать примеры в разговорах о defensive programming), а по отношению к языку Pascal. Так что, во-первых, защищенное программирование -- это общее понятие, которое может быть применено к любому языку программирования. Только вот не в каждом степень успешности этого применения будет одинаковой, о чем я скажу дальше.

Во-вторых, за последние двадцать лет сформировалось достаточно мощное и важное надмножество защищенного программирования, которое иногда называют тем же самым именем, но которое бы следовало называть "защищенное от уязвимостей программирование". Т.е. это техники и инструменты для разработки ПО, призванные не допустить появления в ПО потенциальных уязвимостей, которые могли бы быть использованны злоумышленниками для противоправных действий (как то: захват контроля над удаленной компьютерной системой, кража информации, нарушение работоспособности ПО и т.д.). Для этого направления защищенного программирования есть такие термины, как secure software development, secure development lifecycle. На эту тему намного больше смогут рассказать специалисты по информационной безопасности. И эта тема намного шире, чем простое защищенное программирование, о котором речь пойдет дальше. В частности secure software development уделяет огромное внимание контролю полученной от пользователя информации, тогда как для собственно defensive programming эта проблема не актуальна.

Итак, само по себе defensive programming подразумевает всего лишь три простые вещи:

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

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

    Гораздо более удачным примером является передача отрицательного значения в функцию sqrt или нуля в качестве делителя в функцию div.

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

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

    Аналогично и с инвариантами объекта. Инвариант, говоря по простому -- это согласованное состояние всех атрибутов/полей объекта. Например, вы реализуете контейнер в виде двухсвязного списка и в вашем объекте-контейнере есть указатели на голову/хвост списка, а так же длина списка. Инвариант вашего контейнера выполняется, если указатели на голову/хвост содержат корректные значение, а длина списка точно соответствует количеству элементов в контейнере. Нарушение инварианта может произойти при добавлении очередного элемента, если вы успели инкрементировать длину списка, но не смогли выделить память под новый элемент из-за чего не провязали его в свою цепочку.

    Кстати, проблема сохранения инвариантов есть не только для полей объектов. Но и для глобальных данных программы. Или приватных данных программного модуля/пакета. А так же для циклов.

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

    И здесь оказывается, что есть всего два хороших способа информирования о нарушениях контрактов: насильственное убивание приложения (т.е. вызов abort()) и порождение исключения. Все остальное (как то, возврат кодов ошибки, возврат специальных значений, установка глобальных флагов (см. errno)) -- это все фигня. А использование этой фигни как раз таки сводит на нет достоинства защищенного программирования.

    Специально подчеркну, что если язык программирования не позволяет заявить об обнаруженной проблеме так, чтобы это заявление невозможно было проигнорировать, то нормального использования defensive programming в этом языке не будет.

    Яркий пример -- это приснопамятный C-шный assert. От которого есть польза только в DEBUG-сборках. Но который запросто пропускает ошибки аргументов, возвращаемых значений и нарушения инвариантов в реальной эксплуатации ПО, скомпилированного в RELEASE-режиме. Отчасти этой же проблеме подвержены и механизмы обеспечения Design By Contract в Eiffel (там многие проверки могут быть отключены в параметрах компиляции и повторяется та же история, как с C-шными assert-ами).

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

    Кстати говоря, принцип fail fast вполне может рассматриваться как хорошее воплощение этой практики защищенного программирования.

  3. Воспрепятствование возникновению ошибок. Т.е. использование таких приемов и практик при кодировании (а так же при проектировании), которые исключают целые классы ошибок. Например, если использовать std::vector в C++ вместо ручного выделения памяти под вектора, то устраняется пласт проблем с управлением динамической памятью. Использование механизма RAII (или using в C#, или scope(exit) в D) решает проблему утечек других типов ресурсов. Разумное использование модификатора const в C++ для запрещения модификации объектов. Передача обязательных аргументов по ссылке или по значению, а не по указателю в C/C++. И т.д.

    Какие-то из этих практик/техник могут быть включены в сам язык (например, запрет на уровне языка на объявление переменной без начального значения). Либо же быть частью стандартной библиотеки (см. std::vector, std::unique_ptr в C++). Либо же предоставляться сторонними библиотеками (например, OTL как хорошая обертка над вызовами ODBC).

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

Как раз то, что defensive programming следит только за соблюдением программных контрактов, и является для меня основным водоразделом между defensive programming и secure software development. Допустим, что есть программный модуль, один из методов которого получает строку с параметрами подключения к БД, и использует ее для осуществления цепочки вызовов ODBC-функций. С точки зрения defensive programming метод должен просто убедится, что строка ему была передана, что в ней есть необходимые параметры, что эти параметры должным образом будут извлечены и переданы в ODBC-функции. То, что эта строка может быть получена от пользователя, которому нельзя доверять, и что в ней могут быть какие-то хитрые значения, которые используют некую уязвимость в конкретной версии ODBC-драйвера, с точки зрения defensive programming не важно. Эта проблема должна решаться на каком-то другом уровне, например, в модуле взаимодействия с пользователем. А вот с точки зрения secure sofware development все может быть совершенно иначе. Но, повторюсь, на тему защищенного от уязвимостей программирования лучше выслушать мнение специалистов по информационной безопасности.

На мой взгляд, ключевыми для успешного использования defensive programming являются два первых пункта из трех перечисленных выше: диагностирование и информирование о проблемах. Если эти два пункта должным образом используются, то defensive programming ни в коем случае не скрывает ошибки, как об этом некоторые пытаются говорить. Напротив, defensive programming дает разработчику (именно разработчику, а не пользователю ПО) инструмент для как можно более раннего выявления проблем в run-time.

Однако, нужно понимать, что defensive programming обходится очень недешево. Снабжение программного кода должным количеством проверок увеличивает время разработки. Это точно и, по-хорошему, должно быть очевидно (хотя я не уверен, что все желающие попробовать defensive programming отдают себе в этом отчет). Вставка в код необходимых проверок может сказаться и на сложности разработки. Хотя здесь не так все однозначно. С одной стороны формальная фиксация контрактов для методов/классов/модулей заставляет лучше и глубже их прорабатывать. Но, с другой, облегчается использование уже написанных модулей, т.к. их контракты помогают быстрее понять, каким именно образом модуль должен использоваться, а как его применять ни в коем случае нельзя.

Еще одной вещью придется пожертвовать при использовании defensive programming -- скоростью выполнения кода. Поскольку польза от проверок есть только, если они выполняются. Если же из результирующего кода тем или иным способом проверки изымаются (например, изъятие assert-ов из C/C++ного кода в RELEASE сборках или отключение проверок контрактов в Eiffel), то defensive programming работает только на этапе отладке. В эксплуатации вы вынуждены полагаться на полноту и корректность своего тестирования. Если же проверки в RELEASE-сборках остаются, то скорость исполнения кода с проверками может быть в разы меньше, чем кода без проверок (я проверял это как-то в Eiffel-е, компилируя программу с разным уровнем проверок в коде). Для каких-то задач это может не иметь значения, для каких-то быть просто недопустимым (воспроизведение или перекодирование видео, большие вычисления, криптография и пр.).

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

Затрудняюсь сказать насколько defensive programming актуально сейчас. На мой взгляд, за последние десятилетия акцент сместился на третий пункт из приведенного выше перечня. А именно на средства и инструменты по предотвращению некорректного использования программных модулей/классов/функций. Т.е. разработчики давно пытаются вместо проверки корректности параметров или последовательности API вызовов строить свой API так, чтобы его можно было использовать единственным способом. Например, если ваш API предоставляет класс File, у которого нужно вызвать метод open, а лишь затем использовать методы read/write, то ваш API может быть использован неправильно. В результате ошибки кто-то может вызвать read до open. Может вам было бы лучше сделать класс FileIO, который бы предоставлял только методы read/write и функцию open_file, который бы возвращал FileIO. Т.е. когда нет открытого файла, то не у кого вызывать read.

Сюда же идут различные языковые возможности, которых не было 30-20 лет назад. Такие, как конструкции using в C# или scope(exit) в D. Сюда же поддержка константности и иммутабельности для объектов. Да та же поддержка исключений, которая известна уже давно, но которую в том же C++ до сих пор упорно игнорируют (а в новом Go пытаются изобрести какую-то ущербную замену). Сюда же можно добавить и проникновение в массы функционального программирования, соответствующих языков и используемых там приемов контроля за наличием побочных эффектов (или же паттерн-матчинг с контролем за обработкой всех вариантов).

Сюда же можно добавить и различные верификаторы ПО. Хотя, насколько я помню, серебряную пулю там давно уже никто не обещает :)

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

PS. Не могу не сказать про ублюдочность некоторых аргументов о том, что defensive programming якобы позволяет защищаться от сбоев в аппаратуре. Мол, если вы запишете обход строки так: for(size_t i=0;i<strlen(s);++i), то это будет надежнее, чем если вы запишете условие входа из цикла через неравенство: (i!=strlen(s)). Мол, если случайна элементарная частица прошьет микросхему памяти и вызовет установку случайного бита в i, то первый вариант окажется устойчивым к данному сбою, чем второй. Бред. Я не уверен, что даже разработчики софта для космических аппаратов или для промышленных компьютеров, работающих в условиях повышенной радиации заботятся об этом. Кроме того, допустим, что эта частица не увеличит, а напротив, уменьшит значение i. И ваш код в обоих вариантах проделает несколько лишних итераций. Спасет ли от этого defensive programming и какой-либо из способов записи условия в таком цикле? :)))

Комментариев нет: