28 февраля 2016 г.


Метод string.IsNullOrWhiteSpace, ровно как и string.IsNullOrEmpty поначалу могут казаться полезными утилитными методами. Но на самом деле это не совсем так. В действительности, они заставляют нас думать о равенстве null и пустых строк, что конечно не так.
null - это не пустая строка. null представляет отсутсвие значения.

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

Пример: нормализованный поиск

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

Чтобы не усложнять решение, примем нормализованную форму запроса как строку в верхнем регистре с расположением слов запроса в алфавитном порядке. Не составит труда написать, скажем, пять тестовых сценариев, представленных параметризированными тестами:
[Theory]
[InlineData("Seven Lions Polarized"  , "LIONS POLARIZED SEVEN"  )]
[InlineData("seven lions polarized"  , "LIONS POLARIZED SEVEN"  )]
[InlineData("Polarized seven lions"  , "LIONS POLARIZED SEVEN"  )]
[InlineData("Au5 Crystal Mathematics", "AU5 CRYSTAL MATHEMATICS")]
[InlineData("crystal mathematics au5", "AU5 CRYSTAL MATHEMATICS")]
public void CanonicalizeReturnsCorrectResult(
    string searchTerm,
    string expected)
{
    string actual = SearchTerm.Canonicalize(searchTerm);
    Assert.Equal(expected, actual);
}
Возможная реализация функции нормализации:
public static string Canonicalize(string searchTerm)
{
    return searchTerm
        .Split(new[] { ' ' })
        .Select(x => x.ToUpper())
        .OrderBy(x => x)
        .Aggregate((x, y) => x + " " + y);
}
При такой реализации исходный пользовательский запрос разбирвается пробелами на отдельные слова, каждое слово возводится в верхний регистр, после чего слова сортируются и, наконец, соединяются в одну строку, которая, собственно, и является нормализованной формой пользовательского запроса.

Продолжим: сделаем реализацию надежнее

К сожалению, первая реализация функции получилась не совсем правильной, потому что она неправильно преобразует исходную строку, если в ней есть лишние пробельные символы, как, например, в такиих тестовых сценариях:
[InlineData("Seven  Lions   Polarized", "LIONS POLARIZED SEVEN")]
[InlineData(" Seven  Lions Polarized ", "LIONS POLARIZED SEVEN")]
Новые тесты не проходят, потому что функция неверно обрабатывает несколько идущих подряд пробелов.

В результате небольшого рефакторинга исходного метода, все тесты, в том числе и недавно добавленные, успешно проходят:
public static string Canonicalize(string searchTerm)
{
    return searchTerm
        .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(x => x.ToUpper())
        .OrderBy(x => x)
        .Aggregate((x, y) => x + " " + y);
}
Единственное отличие в добавлении флага StringSplitOptions.RemoveEmptyEntries в качестве второго аргумента метода Split.

Тестирование на Null

Несмотря на то, что все тесты успешено проходят, может у метода есть еще несколько невыявленных проблем?

Есть, по крайней мере, одна: если searchTerm каким-либо образом будет неопределенным, метод сгенерирует NullReferenceException. Действительно, ведь нельзя же вызвать метод Split на пустой ссылке.

Чтобы проверить все пути исполнения кода, мы должны протестировать единственный параметр метода на null, ожидая выброс исключения:
[Fact]
public void CanonicalizeNullThrows()
{
    Assert.Throws<ArgumentNullException>(
        () => SearchTerm.Canonicalize(null));
}
В таком случае, null принимается как недопустимое значение, и я согласен. Поиск для null (воспринимайте его как отстутсвие значения) бессмысленнен, это скорее сигнализирует об ошибке в вызывающем метод коде.

Я часто вижу, как программисты раз за разом выполняют проверку наподобие такой:
public static string Canonicalize(string searchTerm)
{
    if (string.IsNullOrWhiteSpace(searchTerm))
        throw new ArgumentNullException("searchTerm");
 
    return searchTerm
        .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(x => x.ToUpper())
        .OrderBy(x => x)
        .Aggregate((x, y) => x + " " + y);
}
Обратите внимание на использование IsNullOrWhiteSpace. Конечно, все тесты проходят, но все же это неправильно по нескольким причинам.

Проблемы с IsNullOrWhiteSpace

Во-первых, использование IsNullOrWhiteSpace таким образом отдает вызывающему коду ложные сообщения об ошибке. Для примера, если передать методу пустую строку "" (не null) как параметр searchTerm, метод выбросит NullReferenceException!. Это в корне неверно: исключение рассказывает о пустой ссылке, когда это не так (пустая строка не null).

Конечно, можно изменить тип исключения на ArgumentException и добавить к нему поясняющее сообщение:
if (string.IsNullOrWhiteSpace(searchTerm))
    throw new ArgumentException("Empty or null.", "searchTerm");
Конечно, решение корректно, но не так хорошо, как могло бы быть. Другими словами, такое решение не сильно поможет разработчикам, использующим наш метод. Может показаться, что эта не такая серьезная проблема в разрезе единственного метода, но такой небрежный код в итоге лишь усложнит разработку зависящих от такого метода компонентов.

Наконец, разве есть причины не допускать пустую строку как допустимый арумент?

Самое время перейти к заключительным доработкам метода, работающем с пустыми и пробельными строками, как обычными строками. Сначала добавим пару-тройку тестов на такой случай:
[InlineData("", "")]
[InlineData(" ", "")]
[InlineData("  ", "")]
Эти тесты завершатся неудачей из-за использования IsNullOrWhiteSpace, но это легко исправить.

Итоговая версия метода нормализации:
public static string Canonicalize(string searchTerm)
{
    if (searchTerm == null)
        throw new ArgumentNullException("searchTerm");
 
    return searchTerm
        .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(x => x.ToUpper())
        .OrderBy(x => x)
        .Aggregate("", (x, y) => x + " " + y)
        .Trim();
}
Во первых, защитное условие инициирует выброс исключения только при передаче null в качестве аргумента. Все значения, отличные от пустой ссылки корректны. Во вторых, метод использует одну из перегрузок linq метода Aggregate, принимающую пустую строку как начальное значение, используемое для выполнения операции свертки. И наконец, в третьих, завершающий вызов Trim гарантирует, что в результирующей строке не останется либо предшествующих, либо завершающих пробелов.

Суть IsNullOrWhiteSpace

По сути, проблема string.IsNullOrEmpty и string.IsNullOrWhiteSpace в том, что они создают впечатление равентсва null и пустых строк, что не так: пустые строки это самые настоящие строки, с которыми в подавляющем большинстве случаев можно работать также, как и с любыми другими.

Если вы уверены, в пустая строка есть эквивалент null, вы будете выбрасывать их в корзину к "неправильным" данным. Если взглянуть на предусловия классов, методов или функций, можно заметить, что пустые строки, в большинстве случаев, прекрасно укладываются в их область допустимых значений. Так зачем же их отклонять? Такой подход лишь превносит в код ненужную сложность и делает его сложнее для использования.

Если рассуждать в терминах тестирования, по моему опыту, null и пустые строки очень редко относится к одному классу эквивалентности. Поэтому рассматривать их эквивалентно не совсем верно.

Методы string.IsNullOrWhiteSpace и string.IsNullOrEmpty, которые подразумевают равенство null, пустых строк и строк пробельных символов неправильно очерчивают граничные условия в ваших приложениях. Поэтому, будьте внимательны при их использовании.