Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров5.1K
Всего голосов 19: ↑18 и ↓1+25
Комментарии46

Комментарии 46

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

SevenBitNumber y = (SevenBitNumber)100;

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

SevenBitNumber y = new(100);

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

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

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

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

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

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

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

передача такой структур через XMM это нарушение ABI же

Кто ж спорит, что ABI мог быть поприятнее. Но то, что он такой, не позволяет такие абстракции делать zero-cost для произвольных типов данных.

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

Я за все 20 лет программирования на шарпе создал ровно один свой такой вот тип - это был класс Age, в котором нужно было хранить возраст именно так как его задали - в днях, месяцах или годах. И очень скоро этот тип выродился в набор статических методов, а сами данные уместились в int в формате yyyymmdd.

Не располагает шарп к такому. А вот в с++ раньше это делалось налево и направо

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

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

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

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

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

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

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

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

Так имейте.

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

"Invalid note number."

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

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

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

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

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

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

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

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

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

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

Спасибо.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Да, библиотека поддерживает старые версии языка, но для полноты добавил информацию в статью, спасибо.

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

Есть же using и алиасы для этих целей

Они локальны в файле (ну или в крайнем случае при using global - в сборке).
Но в публичном API сборки алиасов нету.

Действительно, вы правы

Спасибо, добавил в статью информацию.

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

public readonly struct SevenBitNumber
{
    public byte Value { get; }
    
    private SevenBitNumber(byte value)
    {
        Value = value;
    }

    public bool TryParse(byte value, out SevenBitNumber sevenBitNumber)
    {
        if (value > 127)
        {
            sevenBitNumber = default;
            return false;
        }

        sevenBitNumber = new SevenBitNumber(value);
        return true;
    }

    public SevenBitNumber Parse(byte value)
    {
        if (TryParse(value, out var sevenBitNumber))
        {
            return sevenBitNumber;
        }

        throw new ArgumentOutOfRangeException(nameof(value), value, "Value is out of range for seven-bit number");
    }
}

Спасибо.

Внутри моей библиотеки у SevenBitNumber/FourBitNumber есть методы Parse/TryParse. Ну и в целом, исключение при приведении типа или исключение при валидации параметра сообщает нам об одном и том же — значение невалидно. В MS это не запрещается, просто преобразование должно быть явным:

If a custom conversion can throw an exception or lose information, define it as an explicit conversion.

Касательно этого утверждения

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

не соглашусь. Как раз про неявное знать необязательно. На то оно и неявное, что никаких рисков нет. Из SevenBitNumber преобразование в byte не сопряжено ни с какими последствиями, поэтому оно неявное. Вероятно, вы имели в виду явное всё же.

Но в конретом коде в статье это проблема, так как есть риск исключения при присваивании, что ну уж совсем не ожидаемо, всё таки метод Parse делает это прозрачным, что вот тут произойдёт преобразование одних данных в другие и есть некоторая логика этого преобразования, неявное преобразование тут подошло бы если бы не было бизнес логики, например у нас есть просто метод обёртка, который через констуктор поместит входные данные во внутреннее поле и всё, например это хорошо работает с Nullable. Ну и ещё аргумент против неявного привеления в том, что многие C# программисты, как и я, любят объявлять переменные через var и очень редко пишут тип вне объявленя параметров, свойств или полей

Ещё один момент в том, что вы преобразовываете число с большим количеством бит в число с меньшим количеством бит, то есть возможна потеря информации, в C# int можно преобразовать к long неявно, но вот long к int только явно

Мне кажется, вы невнимательно прочли статью.

вы преобразовываете число с большим количеством бит в число с меньшим количеством бит, то есть возможна потеря информации

Всё верно, и такое преобразование у меня явное. Также, как long к int. Посмотрите на код ещё раз. explicit operator это оно. Вы же пытаетесь доказать, что у меня исключение в неявном преобразование, но это не так.

Для 127 значений можно подумать в направлении enum-ов. Они дают практически все те же преимущества, но сверх того помогают не перепутать порядок, в котором аргументы передаются в метод (note, velocity или velocity, note), а также позволяют задать человекочитаемую семантику:

enum Pitch : byte
{
  C0 = 0, /*Значения наобум, я понятия не имею, какое соответствие нот кодам*/
  Csharp0 = 1,
  D0 = 2,
  ...
}

Для Velocity польза сомнительна, конечно, но для нот - вполне удобно, мне кажется.

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

Ну и enum не спасёт в случае вычисления значения по какой-то формуле. Придётся результат приводить к этому enum'у. А что если результат есть число, не принадлежащее перечисление? C# всё равно выполнит приведение, есть у enum'ов такая особенность. И пойдёт гулять по программе невалидное значение.

Так я и не призываю все гвозди забивать одним молотком. Ваше решение хорошее и подходит для многих сценариев, но одновременно есть сценарии, где другие решения (например, те же enum-ы или record struct, как в SmartEnum) кажутся более уместными. Пользователю библиотеки знание о том, что какой-то тип занимаем 7 бит, мало что даёт, для него это чаще всего малосущественная деталь реализации. А вот гарантия на уровне типов, что переменная с номером канала не будет записана в поле, ожидающее ноту - это полезно, это удобно.

Что касается возможности получения невалидного значения в enum-е при совершении арифметических операций - так это для любого способа представления так. Контроль выхода за границы необходимо делать независимо от формата. И я не знаю, может быть пользователи библиотеки и правда активно складывают и умножают ноты, но более рациональным видится дать возможность прибавить вычесть интервал (терция, квинта, октава...) или найти разницу между двумя нотами в виде интервала. Такой сценарий опять же удобнее реализовывать через отдельные типы (можно провести аналогию с DateTime + TimeSpan), а не через единый универсальный SevenBitNumber.

Да, соглашусь.

Вообще, в библиотеке касательно вашего подхода есть несколько методов, например:

public static Note Get(NoteName noteName, int octave)

или

public static SevenBitNumber GetNoteNumber(NoteName noteName, int octave)

Длиннее, чем enum, но идея та же, и она верная: по имени ноты и октаве получить ноту/её номер.

  1. Советую присмотреться к двум типовым решениям для создания обёрток:

    1. Использование готовых SourceGenerator'ов или написание своих. Например: https://github.com/andrewlock/StronglyTypedId
      Можно ещё и для еnum'ов генерировать через SourceGenerator'ы обёртки чтобы не писать постоянно Enum.IsDefined. Мы такое реализовали для себя

    2. Написать свой t4 шаблон

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

  3. IComparable это только вершина айсберга. В для таких обёрток придётся реализовывать кучу разных "расширений" для того чтобы их можно было парсить/(де)сериализовывать вашим любимым сериализатором/конвертировать чтобы ORM корректно строила запросы. В общем создавая обёртку мы теряем весь функционал реализованный в различных библиотеках для примитивов.
    Решить эту проблему без поддержки со стороны языка, кажется нельзя.

Спасибо большое за полезный комментарий, добавил информацию в статью.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации