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


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

Здесь не будет очередных попыток на пальцах объяснить SOLID или сказок о чуде микросервисов. Я постараюсь обратить внимание на не самые ходовые приёмы и, в конце концов, просто на любопытные темы.

А начнём мы с валидации значений.

Оглавление

Проблема

Закройте глаза и представьте: перед вами переменная, например, типа int или byte, вы передаёте её в метод, алгоритм нежно шуршит своими шестерёнками, творя чудо, но... Тут вы просыпаетесь и вспоминаете, что не всегда любое число из допустимого диапазона значений имеет смысл в конкретной программной логике, а потому его нужно валидировать.

Дабы не ломать голову над примером, обращусь за помощью к своему реальному опыту. Как вы, возможно, знаете из предыдущих моих статей, источником мыслей и материалов для них является разрабатываемая мной библиотека DryWetMIDI, предоставляющая широкие возможности по работе с MIDI на платформе .NET. Держа в голове, как и ранее, MIDI 1.0, замечу, что в огромном числе мест стандарта подразумевается использование чисел от 0 до 127, например, для указания номера ноты или скорости её нажатия (velocity).

Среди встроенных типов в .NET подходящего нет. Ближайший кандидат — byte. Но диапазон допустимых значений у него от 0 до 255. Выходит, что придётся проверять число, передаваемое пользователем в наш API.

Простое решение

Недолго думая, напишем в начале непонятного (но, несомненно, очень полезного) метода HandleNote такой код:

private static void HandleNote(byte noteNumber, byte velocity)
{
    if (noteNumber > 127)
        throw new ArgumentOutOfRangeException(nameof(noteNumber), noteNumber, "Invalid note number.");

    if (velocity > 127)
        throw new ArgumentOutOfRangeException(nameof(velocity), velocity, "Invalid velocity.");

    // ...
}

Как было сказано ранее, подобные проверки нужны везде, где ожидаются числа в диапазоне 0-127. Так что при написании кода придётся выкрутить внимательность и терпение на максимум.

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

Кастомная структура

Но что если вместо повсеместной проверки аргументов поменять тип у входных данных на свой, внутри которого и будет проверяться значение?

public struct SevenBitNumber
{
    private byte _value;

    public SevenBitNumber(byte value)
    {
        if (value > 127)
            throw new ArgumentOutOfRangeException(
                nameof(value),
                value,
                "Value is out of range for seven-bit number.");

        _value = value;
    }

    public static implicit operator byte(SevenBitNumber number) =>
        number._value;

    public static explicit operator SevenBitNumber(byte number) =>
        new SevenBitNumber(number);
}

И тогда старый метод приобретает новые формы:

private static void HandleNote(SevenBitNumber noteNumber, SevenBitNumber velocity)
{
    // ...
}

Данный подход решает две важные задачи:

  1. сосредоточить логику валидации в одном месте;

  2. явно показать допустимые значения через имя типа.

Что любопытно, за всё время работы в профессии я ни разу не встречался с таким приёмом. Даже готовя статью я нашёл не так уж много информации по теме:

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

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

Но всё ли так замечательно?

Минусы

Если мы получили на каком-то этапе программы экземпляр SevenBitNumber, то благодаря реализации оператора неявного преобразования в byte такой номер пройдёт:

var sevenBitNumber = GetSevenBitNumber();
int x = sevenBitNumber;

А тут уже будет ошибка компиляции:

SevenBitNumber y = 100;

Преобразование из byte в SevenBitNumber должно быть явным (по причине потенциальных потери данных и выброса исключения):

SevenBitNumber y = (SevenBitNumber)100;

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

Как заметил @ColdPhoenix, нынче в C# можно написать так:

SevenBitNumber y = new(100);

Однако тип в левой части выражения поставлен просто для наглядности. Чаще же на этом месте будет стоять var, и тогда тип справа придётся указывать.

Кроме того, @AlexXYZ в своём комментарии навёл на мысль о ещё одном неудобстве. Предположим, есть такой набор методов:

private static void A(SevenBitNumber n)
{
    // ...
}

private static byte B(SevenBitNumber n)
{
    // ...
}

private static byte C(SevenBitNumber n)
{
    // ...
}

private static byte D()
{
    // ...
}

Теперь сделаем такой каскад вызовов этих методов:

A(
    (SevenBitNumber)B(
        (SevenBitNumber)C(
            (SevenBitNumber)D())));

Если же во время исполнения программы мы получим исключение о том, что значение недопустимо для семибитного числа, то мы не поймём, какой из методов напортачил — B, C или D. И хотя писать код подобным образом считаю неправильным (впрочем, возможно, кто-то и любит долгую отладку), пользователь может так сделать. Таким образом, в сложных выражениях может быть затруднительно определить, какая из его частей оказалась невалидной.

Производительность

А что на счёт производительности? — может прозвучать вопрос. Здравый смысл подсказывает: заметно страдать она не должна. В конце концов, встроенные примитивные типы .NET тоже являются структурами. Ну хорошо, byte внутри SevenBitNumber это несколько больше одной только структуры Byte. Значит, самое время создать чистый проект, установить туда BenchmarkDotNet и выполнить замеры.

Определим подопытного:

public struct ByteWrapper
{
    private byte _value;

    public ByteWrapper(byte value)
    {
        if (value > 254)
            value = 254;

        _value = value;
    }

    public static implicit operator ByteWrapper(byte b) =>
        new ByteWrapper(b);

    public static implicit operator byte(ByteWrapper wrapper) =>
        wrapper._value;
}

Здесь всё аналогично рассмотренной выше структуре SevenBitNumber: конструктор, оператор неявного приведения к byte и явного к ByteWrapper. Разве что в теле условного оператора в конструкторе не выбрасывается исключение (ломать временами приятно, но не свою же программу).

И, собственно, бенчмарки:

public class Benchmarks
{
    private const int IterationsCount = 100000000;
    private const int OperationsPerInvoke = 10000;

    private static readonly Random _random = new();
            
    private byte _randomByte;
    private ByteWrapper _randomByteWrapper;

    [IterationSetup]
    public void PrepareRandomByte()
    {
        _randomByte = (byte)(_random.Next() & 0xFF);
        _randomByteWrapper = _randomByte;
    }

    [Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
    public byte CreateByte()
    {
        for (var i = 0; i < IterationsCount; i++)
            _randomByte += _randomByte;

        return _randomByte;
    }

    [Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
    public ByteWrapper CreateByteWrapper()
    {
        for (var i = 0; i < IterationsCount; i++)
            _randomByteWrapper += _randomByteWrapper;

        return _randomByteWrapper;
    }

    [Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
    public byte PassByteToMethod()
    {
        for (var i = 0; i < IterationsCount; i++)
            DoSomethingWithByte(_randomByte);

        return DoSomethingWithByte(_randomByte);
    }

    [Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
    public ByteWrapper PassByteWrapperToMethod()
    {
        for (var i = 0; i < IterationsCount; i++)
            DoSomethingWithByteWrapper(_randomByteWrapper);

        return DoSomethingWithByteWrapper(_randomByteWrapper);
    }

    private static byte DoSomethingWithByte(byte b) =>
        b++;

    private static ByteWrapper DoSomethingWithByteWrapper(ByteWrapper wrapper) =>
        wrapper++;
}

Здесь применены джедайские техники для исключения компиляторных оптимизаций и нулевых результатов. Вот что насчиталось у меня:

Метод

Среднее время (мкс)

CreateByte

17.397

CreateByteWrapper

16.889

PassByteToMethod

3.421

PassByteWrapperToMethod

3.392

Просадки производительности не замечено.

И хотя каждый из нас догадывается, какими будут результаты, выполним измерения ещё с таким типом:

public class ByteWrapperClass
{
    private byte _value;

    public ByteWrapperClass(byte value)
    {
        if (value > 254)
            value = 254;

        _value = value;
    }

    public static implicit operator ByteWrapperClass(byte b) =>
        new ByteWrapperClass(b);

    public static implicit operator byte(ByteWrapperClass wrapper) =>
        wrapper._value;
}

Да, класс вместо структуры. Код бенчмарков приводить не буду, там всё аналогично, разве что включим MemoryDiagnoser для пущей “красоты”:

Метод

Среднее время (мкс)

Выделено памяти (Б)

CreateByte

17.397

CreateByteWrapper

16.889

CreateByteWrapperClass

84.212

240000

PassByteToMethod

3.421

PassByteWrapperToMethod

3.392

PassByteWrapperClassToMethod

43.476

240000

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

К слову, в начале раздела было такое высказывание:

byte внутри SevenBitNumber это несколько больше одной только структуры Byte

А больше ли?

var unmanagedSize = Marshal.SizeOf(typeof(ByteWrapper));
Console.WriteLine($"Unmanaged size = {unmanagedSize} byte(s)");

var managedSize = Unsafe.SizeOf<ByteWrapper>();
Console.WriteLine($"Managed size = {managedSize} byte(s)");

Вывод консоли:

Unmanaged size = 1 byte(s)
Managed size = 1 byte(s)

Получается, размер экземпляра нашего нового типа, который есть обёртка над byte, размеру байта и равен.

Заключение

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

Наверняка вы захотите использовать такие кастомные числа в методах OrderBy() или же List<T>.Sort(). Тогда нужно будет реализовать интерфейс IComparable<T>. Также временами пригождаются методы из класса Convert. Чтобы можно было передавать в них новый тип, придётся реализовать интерфейс IConvertible. Но бояться этого не стоит: в реализациях методов указанных интерфейсов нужно будет просто вызвать те же методы у поля, вокруг которого и построена обёртка.

Также не забывайте, что для значимых типов всегда нужно переопределять метод Equals, если вы не хотите на ровном месте иметь проблемы с производительностью (напомню, по умолчанию структуры сравниваются через рефлекшн, см. How to define value equality for a class or struct).

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

Комментарии (26)


  1. ColdPhoenix
    27.07.2024 19:40
    +1

    На самом деле вместо

    SevenBitNumber y = (SevenBitNumber)100;

    можно сделать

    SevenBitNumber y = new(100);


    1. Dotarev
      27.07.2024 19:40
      +1

      По сути, оба оператора используют преобразование константы в структуру во время выполнения. А теперь вопрос к джедаям: А можно каким-то образом перенести проверку на время компиляции?


      1. Cregennan
        27.07.2024 19:40

        Roslyn анализатор можно написать, который будет сборку блокировать если увидит неправильный каст


    1. Melanchall Автор
      27.07.2024 19:40

      Спасибо, добавил в статью. Правда, работать это будет только до тех пор, пока мы не захотим использовать слева var.


  1. a-tk
    27.07.2024 19:40

    Интересно, а приколы с float/double в структурах-обёртках уже пофиксили, или они через стек между XMM и GPR ходят при вызове до сих пор?


    1. a-tk
      27.07.2024 19:40
      +1

      А что вызвало минус-то?

      https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKGIAYACY8gOgCUBXAOwwEt8YLAMIR8AB14AbGFADKMgG68wMXAG4aNYigYAVVRnIAKAJQMAvAD4GQlgFkjXGAHcGAdSjYxYmQB5ePJZG5CYmGrQ6+rgYpKYW1rYOIeHaegYAzHFWNvaOLu6e3n4AZpIQ2BhBrHTFoSmRBihZCbnVteFa6QywkFAAJgzRUBxgGAVePlC+ukG6DABq2JIcMGbOABYyMHoMIIMYw6MdtF1kNgw0AN40DLcMANp2MBjrEH0AkuKSRk8vb59iSQAeTEfAgXFwLAAchB3lxJAEAgBzEwAXRud2Ip3ISCYOgcHgmfgClQY2CgKIu1DuDGu1LuAF8MbdHs9Xh8vj82f8viCwRDobD4YiuCj0fTblimDi8QwHCSyRSzMzaSqmRKqTTWX8OYCuTqAcDQbxwZCYXCEVxkWiVVLmLjUgTCpNfKVyqTnM6ZMqNXSaeqtb92Yb9cHecbTYKLSKxbbsQ78UY3RVFZSVX7GTR1UA

      Вот тут видно, что для обёртки поверх float передача такого аргумента уже не дармовое, в отличие от int.


  1. Aeternamens
    27.07.2024 19:40
    +2

    Удивило, что почти никто не создаёт свой тип. Это же первое, что приходит на ум.


  1. AlexXYZ
    27.07.2024 19:40

    Видел уже такой подход ранее. Лично мне он не особо нравится по следующей причине:

    1. бросание исключений при валидации - так себе подход, но надо смотреть на контекст исключения, обычно это чревато.

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

    3. Скорее всего по параметрам должен быть выполнен расчёт на основе нескольких формул и пользователь скорее всего ожидает хотя бы минимальный результат. Пусть не весь расчёт, но хоть предварительные данные без финального результата тоже интересны. И их бы тоже хотелось бы получить не смотря на то, что часть параметров пришла в негодность.

    4. Хотелось бы иметь кэш валидации параметров, чтобы не запускать все эти формулы валидации каждый раз, когда потребовался параметр для расчёта в другой формуле.


    1. dyadyaSerezha
      27.07.2024 19:40
      +2

      Кэш валидации? Искать в словаре быстрее оператора сравнения??


      1. AlexXYZ
        27.07.2024 19:40

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


        1. dyadyaSerezha
          27.07.2024 19:40

          Хотелось бы иметь кэш валидации

          Так имейте.


    1. Melanchall Автор
      27.07.2024 19:40

      Спасибо, вы навели меня на мысль, дополнил раздел Минусы. Но, кажется, вы говорите о чём-то другом. Можете, пожалуйста, показать пример, где возникают сложности? Это я про ваш пункт 2.


  1. dyadyaSerezha
    27.07.2024 19:40
    +7

    "Invalid note number."

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


    1. AlexXYZ
      27.07.2024 19:40
      +1

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


      1. dyadyaSerezha
        27.07.2024 19:40

        Ну, сообщения как раз разные, потому что с именем параметра.


    1. Melanchall Автор
      27.07.2024 19:40

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


  1. Sabirman
    27.07.2024 19:40

    А какое сообщение в итоге увидит пользователь ?

    Value is out of range for seven-bit number - совершенно негодится:

    Во первых, не локализована,

    Во вторых, нет названия парамнтра,

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

    В четвертых, ваш алгоритм не проверяет все поля, а завершится на первом же неверном значении.


    1. Melanchall Автор
      27.07.2024 19:40

      Спасибо.

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

      2. Да, это минус. С другой стороны, если вы получите сообщение с именем параметра, это всё равно ничего вам не скажет о том, почему в него попало невалидное значение.

      3. Если речь про отладку и сложные выражения, да, есть такой момент, дописал в статью.

      4. Верно. Чтобы сделать статью лучше, покажите, пожалуйста, пример, я с радостью добавлю.


  1. Gromilo
    27.07.2024 19:40

    Смотрел выхлоп компилятора для инта обёрнутого в структуру. В релизной версии никаких изменений, просто инт бегает по программе. Так что в каком-то смысле техника бесплатная.

    В моём случае было так: из другой системы приходило количество товара, где 0 означало на заказа. В итоге в программе как-то незаметно стали плодиться проверки вида x.Quantity == 0. В какой-то момент решили, что хватит это терпеть, завернулись в структуру и сделали метод IsOnOrder(). А почему этот метод добавлен к количеству, а к тому где оно лежит? А потому что были алгоритмы, которые чисто с количеством работали.


    1. AlexXYZ
      27.07.2024 19:40

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


      1. Gromilo
        27.07.2024 19:40

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


  1. Worgen
    27.07.2024 19:40

    А чем обычный FluentValidator не устраивает?


    1. Melanchall Автор
      27.07.2024 19:40
      +2

      Чем не устраивает, написано в следующем абзаце в статье:

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


  1. funny_falcon
    27.07.2024 19:40

    Только сегодня наконец прочитал успевшую стать классикой “Parse, don’t Validate”, и тут ещё одна статья с таким же посылом.


  1. mvv-rus
    27.07.2024 19:40

    Что любопытно, за всё время работы в профессии я ни разу не встречался с таким приёмом.

    Новое - это хорошо забытое старое. В Pascal (ещё в исходном, который от Вирта), например, были ограниченные типы.

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

    Сейчас есть record struct, который это делает сам. Но, насколько я помню, ваша библиотека поддерживает (или поддерживала) старые версии C#, где этого не было.


  1. justmara
    27.07.2024 19:40
    +1

    есть ещё другая история, лежащая несколько в параллельной плоскости - в проекте может быть необходимость иметь иерархический справочник, который в коде будет выглядеть чем-то типа `IDictionary<long, IDictionary<long, IList<long>>>` и умаешься везде вспоминать, что этот конкретный long значит. поэтому часто пользуемся вот этой либой и конструкция превращается в `IDictionary<PublisherId, IDictionary<AuthorId, IList<BookId>>>` и в любом методе аргумент вместо безликого long становится сразу понятным и строго типизованным.