Заметки по архитектуре .NET библиотеки: кастомные структуры как средство валидации значений

2d4e4880b18072e0f4d464dd95570ba7.png

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

Ранее я уже публиковал большой материал касательно самых разных аспектов разработки .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;

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

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

А что на счёт производительности?  — может прозвучать вопрос. Здравый смысл подсказывает: заметно страдать она не должна. В конце концов, встроенные примитивные типы .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();
Console.WriteLine($"Managed size = {managedSize} byte(s)");

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

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

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

Заключение

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

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

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

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

© Habrahabr.ru