Комментарии 46
На самом деле вместо
SevenBitNumber y = (SevenBitNumber)100;
можно сделать
SevenBitNumber y = new(100);
По сути, оба оператора используют преобразование константы в структуру во время выполнения. А теперь вопрос к джедаям: А можно каким-то образом перенести проверку на время компиляции?
Спасибо, добавил в статью. Правда, работать это будет только до тех пор, пока мы не захотим использовать слева var
.
Интересно, а приколы с float/double в структурах-обёртках уже пофиксили, или они через стек между XMM и GPR ходят при вызове до сих пор?
А что вызвало минус-то?
https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKGIAYACY8gOgCUBXAOwwEt8YLAMIR8AB14AbGFADKMgG68wMXAG4aNYigYAVVRnIAKAJQMAvAD4GQlgFkjXGAHcGAdSjYxYmQB5ePJZG5CYmGrQ6+rgYpKYW1rYOIeHaegYAzHFWNvaOLu6e3n4AZpIQ2BhBrHTFoSmRBihZCbnVteFa6QywkFAAJgzRUBxgGAVePlC+ukG6DABq2JIcMGbOABYyMHoMIIMYw6MdtF1kNgw0AN40DLcMANp2MBjrEH0AkuKSRk8vb59iSQAeTEfAgXFwLAAchB3lxJAEAgBzEwAXRud2Ip3ISCYOgcHgmfgClQY2CgKIu1DuDGu1LuAF8MbdHs9Xh8vj82f8viCwRDobD4YiuCj0fTblimDi8QwHCSyRSzMzaSqmRKqTTWX8OYCuTqAcDQbxwZCYXCEVxkWiVVLmLjUgTCpNfKVyqTnM6ZMqNXSaeqtb92Yb9cHecbTYKLSKxbbsQ78UY3RVFZSVX7GTR1UA
Вот тут видно, что для обёртки поверх float передача такого аргумента уже не дармовое, в отличие от int.
Удивило, что почти никто не создаёт свой тип. Это же первое, что приходит на ум.
Я за все 20 лет программирования на шарпе создал ровно один свой такой вот тип - это был класс Age, в котором нужно было хранить возраст именно так как его задали - в днях, месяцах или годах. И очень скоро этот тип выродился в набор статических методов, а сами данные уместились в int в формате yyyymmdd.
Не располагает шарп к такому. А вот в с++ раньше это делалось налево и направо
Видел уже такой подход ранее. Лично мне он не особо нравится по следующей причине:
бросание исключений при валидации - так себе подход, но надо смотреть на контекст исключения, обычно это чревато.
Если какой-то параметр зависит от нескольких других (например, нужно посчитать формулу из нескольких параметров) и результат расчёта формулы даёт результат, который не вписывается в допустимый диапазон, то сказать по формуле, кто из параметров привнёс ошибку в расчёт невозможно и нужно каким-то способом объявить все параметры, входящие в формулу как ошибочные. Но прописывать одну и тоже формулу в валидацию в несколько параметров - далеко не оптимальный способ. К тому же валидации иногда бывают транзитивные, а насколько это реально сделать тут - сомнительно.
Скорее всего по параметрам должен быть выполнен расчёт на основе нескольких формул и пользователь скорее всего ожидает хотя бы минимальный результат. Пусть не весь расчёт, но хоть предварительные данные без финального результата тоже интересны. И их бы тоже хотелось бы получить не смотря на то, что часть параметров пришла в негодность.
Хотелось бы иметь кэш валидации параметров, чтобы не запускать все эти формулы валидации каждый раз, когда потребовался параметр для расчёта в другой формуле.
Кэш валидации? Искать в словаре быстрее оператора сравнения??
Спасибо, вы навели меня на мысль, дополнил раздел Минусы. Но, кажется, вы говорите о чём-то другом. Можете, пожалуйста, показать пример, где возникают сложности? Это я про ваш пункт 2.
"Invalid note number."
Всегда не любил такие ничего не говорящие сообшения об ошибке, потому что у пользователя библиотеки сразу возникает вопрос: блин, а какое число валидное?? Поэтому лучше чётко писать: должно быть между 0 и 127.
Ещё бы хорошо сопоставить с исходным кодом, а то хуже, чем ничего не говорящее сообщение об ошибке могут быть несколько одинаковых ничего не говорящих сообщений об ошибке.
Согласен. В статье просто пример подхода. Хотя, признаюсь, грешу иногда такими малозначимыми сообщениями. Нужно будет пройтись по библиотеке проверить. Спасибо!
А какое сообщение в итоге увидит пользователь ?
Value is out of range for seven-bit number - совершенно негодится:
Во первых, не локализована,
Во вторых, нет названия парамнтра,
В третьих, должно быть обьяснение и курсор должнн стоять на неправильно заполненном поле
В четвертых, ваш алгоритм не проверяет все поля, а завершится на первом же неверном значении.
Спасибо.
Статья не про локализацию, а про подход. Локализовывать здорово, но в библиотеке я этим не занимаюсь, считаю, что английского вполне хватит. Если честно, по пальцам пересчитать библиотеки, которые выводят локализованные сообщения об ошибках.
Да, это минус. С другой стороны, если вы получите сообщение с именем параметра, это всё равно ничего вам не скажет о том, почему в него попало невалидное значение.
Если речь про отладку и сложные выражения, да, есть такой момент, дописал в статью.
Верно. Чтобы сделать статью лучше, покажите, пожалуйста, пример, я с радостью добавлю.
Мне кажется, вы перепутали валидацию значений в 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 становится сразу понятным и строго типизованным.
Выбрасывание исключений в констукторе не самое ожидаемое поведение, неявное приведение типов хоть и выглядит интересно, но лучше его не использовать, так как от пользователя вашего класса требуется знать об этом поведении. Вместо этого можно воспользоваться проверенным способом, использовать методы 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
, но идея та же, и она верная: по имени ноты и октаве получить ноту/её номер.
Советую присмотреться к двум типовым решениям для создания обёрток:
Использование готовых SourceGenerator'ов или написание своих. Например: https://github.com/andrewlock/StronglyTypedId
Можно ещё и для еnum'ов генерировать через SourceGenerator'ы обёртки чтобы не писать постоянно Enum.IsDefined. Мы такое реализовали для себяНаписать свой t4 шаблон
Так же в статье не упомянута важная проблема создания структур через default, который весело проигнорирует все валидации
IComparable это только вершина айсберга. В для таких обёрток придётся реализовывать кучу разных "расширений" для того чтобы их можно было парсить/(де)сериализовывать вашим любимым сериализатором/конвертировать чтобы ORM корректно строила запросы. В общем создавая обёртку мы теряем весь функционал реализованный в различных библиотеках для примитивов.
Решить эту проблему без поддержки со стороны языка, кажется нельзя.
Заметки по архитектуре .NET библиотеки: кастомные структуры как средство валидации значений