Надёжность кода и NullReferenceException

в 12:05, , рубрики: .net, надежность, Песочница, Программирование, метки: , , ,

Во-первых, что понимать под надежностью применительно к программам? Понятие надежности изначально было исключительно инженерным. Согласно определению из Wiki, надежность – это свойство объекта сохранять работоспособное состояние в течение некоторого времени. Под «объектом» здесь понимается некая физическая система. И, как правило, чем сложнее эта система, те есть, чем большее количество элементов в нее входит, тем менее она надежна, поскольку вероятность отказа системы равна произведению вероятностей отказа ее частей (грубое приближение, если не учитывать дублирования и разной степени критичности компонентов). То же самое вполне применимо и к программным системам – чем сложнее программа, тем больше количество ошибок в ней. Однако между физическими системами и программными есть одно принципиальное различие.

Возьмем какой-нибудь узел самолета, например механизм, поворачивающий элероны. Он состоит из набора механических и электронных компонентов, которые, работая согласованно, производят нужное действие – поворачивают элерон. Рассчитать надежность этого механизма мы теоретически можем, для этого нужно лишь знать надежность отдельных его элементов. Допустим, одним из этих элементов является обычный шуруп, который, тем не менее, критически важен для работы механизма – если он сломается, элерон перестанет поворачиваться. Как определить надежность этого шурупа? Можно взять партию таких шурупов и провести ряд испытаний на прочность, определив таким образом некоторую статистическую величину и предположив что данный конкретный шуруп не очень сильно выбивается из этого ряда. Предположение, в общем-то ничем не обоснованное и весьма опасное. Можно пойти другим путем: взять этот шуруп, просканировать его атом за атомом и построить квантово-механическую модель, которая будет учитывать все микроскопические дефекты и неровности. Затем, просчитав эту модель на огромном суперкомпьютере, посмотреть, как этот шуруп поведет себя при разных нагрузках и не сломается ли. Идея захватывающая, но абсолютно бессмысленная. Потому что мы не сможем предсказать, какие нагрузки будут действовать на этот шуруп, поскольку зависят они от множества факторов, таких как порывы ветра, действия пилота, механическая реакция других частей самолета. И чтобы смоделировать все эти факторы, нам придется загнать в матрицу весь самолет с пилотом в придачу, а так же всю атмосферу Земли, плюс учесть магнитные возмущения на солнце и гравитационные волны от взрыва сверхновой в соседней галактике.

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

Рассмотрим такой пример. В .Net Framework у объекта String есть метод Substring:

        public string Substring(int startIndex)

Этот метод возвращает подстроку в строке, начиная с позиции startIndex и до конца строки. Допустим, нам потребовался метод, который возвращает три последних символа имени файла, и мы написали следующий код:

        static string Extension(string name)
        {
            return name.Substring(name.Length-3);
        }

Хорош ли этот код? Да, он делает то, что требуется – возвращает три последних символа. И работает он прекрасно, но до тех пор, пока на вход не придет имя файла длиной меньше трех символов. И мы получим исключение типа ArgumentOutOfRangeException в самый неподходящий момент.

Что произошло? А произошло то, что у метода Substring есть область определения и она была нарушена. Аргумент startIndex может принимать значения в диапазоне [0, Length] где Length длина строки. Если взять двухмерную систему координат, где по оси X будет длина строки, а по оси Y начальный индекс, мы получим треугольную область в правой верхней четверти, которая уходит в бесконечность. Теперь изобразим множество значений, которое может передаваться методу Substring в нашей реализации. Это множество описывается уравнением startIndex = Length – 3. Обычное уравнение линии. При Length= 0 startIndex= -3, при Length = 3 startIndex = 0. Теперь на графике отчетливо проявляется причина сбоя в программе: область возможных входных значений выходит за пределы области определения метода.
image
Ошибку мы нашли. Как ее исправить? Это зависит от того, что мы хотим получить, то есть от требований к данному методу Extension. Можно перед вызовом Substring вставить условие

        If(name.Length < 3) return String.Empty;

Или переписать метод следующим образом:

        static string Extension(string name)
        {
            int index = name.Length - 3;
            if (index < 0) index = 0;
            return name.Substring(index);
        }

Поведение у этих реализаций разное, но в обоих случаях все возможные значения аргументов лежат внутри области определения метода Substring.

Думаю, вы заметили, что в нашем методе Extension есть еще один баг. В качестве аргумента name может быть передан null, что вызовет до боли всем знакомый NullReferenceException. Этот тип багов рассмотрим на следующем примере.

Пример взят из реальной практики. Есть очень простой метод, который преобразует объект типа Entity в объект типа DTO и служит для изоляции уровня бизнес-логики от уровня доступа к данным:

        static ProductDto Translate(ProductEntity e)
        {
            return new ProductDto()
            {
                Name = e.Name,
                Code = e.Code,
                Description = e.Description
            };
        }

Если рассмотреть аргумент е как указатель, то он может принимать всего два значения: null и ссылку на объект. И из этих двух значений лишь последнее входит в область определения метода Translate. И ели где-то в программе этот метод будет вызван с аргументом null, он упадет.

Однако теперь, в отличие от метода Substring, нам доступна его реализация. Так почему бы не расширить эту область определения, добавив в начало метода следующую строку:

if (e == null) return null;

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

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

Предположим, что этот метод является частью программы, которая получает список объектов из базы данных и отображает его на UI. При этом слева выводится список кратких наименований, в котором пользователь может выбрать элемент, в результате чего справа отобразится подробная информация об объекте. И вот как раз при получении подробной информации используется метод Translate: в процедуру в блоке DB передается идентификатор объекта, эта процедура выполняет запрос, пакует результат в объект и передает в наш метод Translate.
Надёжность кода и NullReferenceException
Здесь возникает второй вопрос: что должна делать процедура в блоке DB, если объект с переданным идентификатором не существует в базе данных? Должна ли она возвращать null? Нет, и вот по какой причине.

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

Ок, с уровнем доступа к данным разобрались. Однако, вопрос о том, что должна делать процедура Translate при получении null, остается открытым. Если уровень доступа к данным реализован правильно, то оттуда никогда не придет null, и необходимости в проверке аргумента и выбрасывании исключения нет. То есть в данном контексте мы уверены в том, что значения аргумента будут лежать внутри области определения метода. Следовательно, метод вообще можно оставить в первоначальном виде – никак не проверяя аргумент и не выбрасывая исключения. Но. Всегда есть более широкий контекст.

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

О чем свидетельствует поступление null на вход Translate в описанном выше контексте? Очевидно, о том, что где-то что-то сломалось. То есть значение null в данном контексте имеет смысл «где-то что-то сломалось». Однако вполне возможен и другой контекст.

Допустим, теперь нам потребовалось сделать пользовательский интерфейс для поиска объекта в базе данных по его уникальному кодовому обозначению. Написан SQL запрос, который возвращает ноль либо одну запись. Написан новый метод в блоке DB, который соответственно возвращает либо найденный объект, либо null. В данном случае null – вполне логичный результат, поскольку вводимый пользователем код объекта вполне может оказаться неверным, в каковом случае на UI выводится вполне user-friendly сообщение «Объект не найден». То есть в данном контексте значение null несет совсем другую смысловую нагрузку. И что произойдет, если мы попытаемся использовать в данном контексте готовый метод Translate? Вопрос риторический…
Надёжность кода и NullReferenceException
Таким образом, если мы хотим, чтобы метод Translate надежно работал в обоих контекстах, мы должны сделать его область определения максимально широкой, добавив строку

if (e == null) return null;

в начало метода. В самом деле, в первом контексте уровень доступа к данным не может вернуть null, а во втором null является вполне допустимым значением.

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

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

Продолжение следует.

Автор: microzivert

Поделиться

* - обязательные к заполнению поля