Пишем Guard

в 8:34, , рубрики: .net, check, contracts, copy paster, exceptions, github, guard, java, nuget, open source, opensource, validation, исключения, кодогенерация, проверки, разработка

Пишем Guard - 1

Привет!

Есть несколько способов проверять аргументы на правильность. Например, для проверки на null можно использовать:

  1. if (!ReferenceEquals(arg, null)) throw…
  2. Code Contracts: Contract.Requires(!ReferenceEquals(arg, null))
  3. Guard.IsNotNull(arg, nameof(arg))

В статье я рассмотрю только третий вариант (все примеры кода — для C#, однако некоторые из них будут полезны и в Java).

Ошибка №1: не подготовились к проверкам аргументов и в теле метода

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

Итак, для начала лучше всего заранее договориться об именованиях обоих случаев. Например, Guard.IsNotNull для тела метода и Guard.ArgumentIsNotNull для аргументов

Ошибка №2: вызов string.format при каждой проверке

Сразу примеры ошибочного кода:

Guard.IsNotNull(connection, $"Unable to setup connection with host {host} and port {port}")
Guard.IsNotNull(connection, string.Format("Unable to setup connection with host {0} and port {1}", host, port)) // это просто развернутая строчка выше

Оба примера выше генерят строку на каждую проверку (в теории, все Guard не бросают исключений на боевом сервере, то есть мы генерим немало строк просто для того, чтобы их потом съел сборщик мусора).
Исправленный вариант:

public static class Guard
{
    public static void IsNotNull(object value, string format, params object[] formattingArgs) // тут мы сконструируем строку в самый последний момент, когда выделение небольшого куска памяти уже не будет ударять по производительности
}

На самом деле вариант выше тоже плохой. В нем уже не создается строка, однако на каждый вызов метода создается новый массив formattingArgs. Конечно, для него выделяется меньше памяти, однако такие методы всё равно будут нагружать на сборщик мусора.
Самое обидное, что программа будет тормозить, однако простой профайлинг не подстветит проблему. У вас просто программа будет чаще останавливаться для очистки памяти (я исправил такую ошибку в одной из программ, сборка мусора стала занимать вместо прежних 15% всего лишь 5%).

Итак, поступаем так же, как сделано в string.Format: нагенерим побольше методов для разного числа аргументов.

public static class Guard
{
    public static void IsNotNull(object value, string errorMessage)

    public static void IsNotNull(object value, string errorMessageFormat, object arg1)

    public static void IsNotNull(object value, string errorMessageFormat, object arg1, object arg2)

    public static void IsNotNull(object value, string errorMessageFormat, object arg1, object arg2, object arg3)
}

Итак, теперь массив выделяться не будет. Однако, мы автоматом получили новую проблему — Boxing.
Рассмотрим вызов метода: Guard.IsNotNull(connection, "Unable to setup connection with host {0} and port {1}", host, port). Переменная port имеет тип int (чаще всего по крайней мере). Получается, что для того, чтобы передать переменную по значению, .Net каждый раз будет создавать int в куче, чтобы передать его как object. Эта ситуация будет встречаться намного реже, но всё же будет.
И другая проблема — если изначальный проверяемый объект — это value type (например, мы проверяем на null в generic методе, который не имеет ограничений на тип).
Исправить это можно увеличением созданием Generic методов для проверок:

public static class Guard
{
    public static void IsNotNull<TObject>(TObject value, string errorMessage)

    public static void IsNotNull<TObject, TArg1>(TObject value, string errorMessageFormat, TArg1 arg1)

    public static void IsNotNull<TObject, TArg1, TArg2>(TObject value, string errorMessageFormat, TArg1 arg1, TArg2 arg2)

    public static void IsNotNull<TObject, TArg1, TArg2, TArg3>(TObject value, string errorMessageFormat, TArg1 arg1, TArg2 arg2, TArg3 arg3)
}

Ошибка №3: отсутствие кодогенерации

Как видно выше, для того, чтобы удобно проверять значения на null нам надо:

  1. Создать два набора функций — для проверки аргументов и для проверок в теле метода
  2. В каждом наборе — создать кучу дубликатов, который будут содержать один и тот же код

Без кодогенерации относительно сложно добавлять новые функции, а уж тем более менять их.

Еще улучшения

Пункты ниже не ускорят вашу программу, а просто незначительно улучшат читаемость

ReSharper Annotations

Часто ReSharper ругается, что значение может быть null, хотя его вроде бы проверили с помощью Guard'а. В этом случае можно либо начать постепенно забивать на предупреждения в коде (что может быть чревато), либо объяснить проверяющим, что всё нормально. Полный список аттрибутов можно просмотреть здесь, однако вот полезные для нас:

  • AssertionMethodAttribute и AssertionConditionAttribute — они вдвоем объяснят системе, что метод только проверяет аргумент, а заодно и распишут, что именно проверяют
  • NoEnumerationAttribute — покажет, что если передать на вход IEnumerable, то по нему никто не будет итерироваться
  • CollectionAccessAttribute — если вы вдруг решили проверить все аргументы коллекции (например, что их не больше пяти, и что они не null), то с помощью этого аттрибута можно сказать, что именно происходит с коллекцией (чтение, запись и т.д.)
  • StringFormatMethodAttribute — объясняет, что один из параметров является строкой, которую потом будут использовать как формат. В этом случае её дополнительно проверят, что она может быть разобрана string.Format, что число аргументов соответствует ожиданиям и т.д. Я, кстати, еще не видел проекта, в котором была бы отключена эта проверка, и в котором после её включения не было бы ошибок, что string.Format просто не может выполниться

Записывать больше информации

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

  1. Тип (а лучше — вызов ToString у него), который оказался некорректным.
  2. Если есть еще аргументы для сравнения — то информацию о них тоже
  3. Полный stacktrace (т.к. иначе он обрезается до места, где исключение было поймано)

Заключение

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

Автор: imanushin

Источник

Поделиться

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