Ах, если бы пользователи всегда разбирались в предметной области и передавали в наши замечательные алгоритмы только допустимые данные… Но реальность беспощадна, и проверки аргументов — необходимость. В статье посмотрим, как определение своего значимого типа может с этим помочь.
Ранее я уже публиковал большой материал касательно самых разных аспектов разработки .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)
{
// ...
}
Данный подход решает две важные задачи:
сосредоточить логику валидации в одном месте;
явно показать допустимые значения через имя типа.
Что любопытно, за всё время работы в профессии я ни разу не встречался с таким приёмом. Даже готовя статью я нашёл не так уж много информации по теме:
Type-proofing primitive .NET value types via custom structs: Is it worth the effort?
Standard primitive types vs custom structs in public API (ссылка на мой вопрос на Code Review Stack Exchange прямиком из 2017-го года)
В вопросе по первой ссылке человек преследовал ещё одну цель: предотвратить семантическую путаницу при циркуляции данных по программе. То есть, при одинаковых допустимых значениях не дать передать, например, площадь туда, где ожидается длина. Для меня такой бонус вещь сомнительная, и, если всерьёз думать в этом ключе, код будет запружен понятными одному лишь автору типами.
В комментариях @justmara указал ещё один интересный вариант использования кастомной структуры — в качестве псевдонима для стандартного типа:
в проекте может быть необходимость иметь иерархический справочник, который в коде будет выглядеть чем-то типа
IDictionary<long, IDictionary<long, IList<long>>>
и умаешься везде вспоминать, что этот конкретныйlong
значит. поэтому часто пользуемся вот этой либой и конструкция превращается вIDictionary<PublisherId, IDictionary<AuthorId, IList<BookId>>>
Возвращаясь к обозначенным выше задачам, без лишней скромности стоит признать — решение выглядит неплохо. Больше не нужно везде проверять семибитные числа, приходящие от пользователя, новая структура сделает это за нас.
Но всё ли так замечательно?
Минусы
Если мы получили на каком-то этапе программы экземпляр 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). Если же вы работаете с последними версиями C#, то можете воспользоваться предложением @mvv-rus и взять на вооружение record struct
(подробности в общей статье про записи).
Без сомнения, оборачивать все числа в коде в свои типы будет плохой идеей и может обернуться головной болью. Как и с любыми архитектурными решениями, нужно знать меру и применять их с осознанием последствий. В DryWetMIDI только две такие структуры — FourBitNumber и SevenBitNumber. Используются они часто, а потому наличие своё оправдывают целиком и полностью.
Комментарии (42)
a-tk
27.07.2024 19:40Интересно, а приколы с float/double в структурах-обёртках уже пофиксили, или они через стек между XMM и GPR ходят при вызове до сих пор?
a-tk
27.07.2024 19:40+1А что вызвало минус-то?
https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKGIAYACY8gOgCUBXAOwwEt8YLAMIR8AB14AbGFADKMgG68wMXAG4aNYigYAVVRnIAKAJQMAvAD4GQlgFkjXGAHcGAdSjYxYmQB5ePJZG5CYmGrQ6+rgYpKYW1rYOIeHaegYAzHFWNvaOLu6e3n4AZpIQ2BhBrHTFoSmRBihZCbnVteFa6QywkFAAJgzRUBxgGAVePlC+ukG6DABq2JIcMGbOABYyMHoMIIMYw6MdtF1kNgw0AN40DLcMANp2MBjrEH0AkuKSRk8vb59iSQAeTEfAgXFwLAAchB3lxJAEAgBzEwAXRud2Ip3ISCYOgcHgmfgClQY2CgKIu1DuDGu1LuAF8MbdHs9Xh8vj82f8viCwRDobD4YiuCj0fTblimDi8QwHCSyRSzMzaSqmRKqTTWX8OYCuTqAcDQbxwZCYXCEVxkWiVVLmLjUgTCpNfKVyqTnM6ZMqNXSaeqtb92Yb9cHecbTYKLSKxbbsQ78UY3RVFZSVX7GTR1UA
Вот тут видно, что для обёртки поверх float передача такого аргумента уже не дармовое, в отличие от int.
Aeternamens
27.07.2024 19:40+2Удивило, что почти никто не создаёт свой тип. Это же первое, что приходит на ум.
xxxDef
27.07.2024 19:40Я за все 20 лет программирования на шарпе создал ровно один свой такой вот тип - это был класс Age, в котором нужно было хранить возраст именно так как его задали - в днях, месяцах или годах. И очень скоро этот тип выродился в набор статических методов, а сами данные уместились в int в формате yyyymmdd.
Не располагает шарп к такому. А вот в с++ раньше это делалось налево и направо
AlexXYZ
27.07.2024 19:40+1Видел уже такой подход ранее. Лично мне он не особо нравится по следующей причине:
бросание исключений при валидации - так себе подход, но надо смотреть на контекст исключения, обычно это чревато.
Если какой-то параметр зависит от нескольких других (например, нужно посчитать формулу из нескольких параметров) и результат расчёта формулы даёт результат, который не вписывается в допустимый диапазон, то сказать по формуле, кто из параметров привнёс ошибку в расчёт невозможно и нужно каким-то способом объявить все параметры, входящие в формулу как ошибочные. Но прописывать одну и тоже формулу в валидацию в несколько параметров - далеко не оптимальный способ. К тому же валидации иногда бывают транзитивные, а насколько это реально сделать тут - сомнительно.
Скорее всего по параметрам должен быть выполнен расчёт на основе нескольких формул и пользователь скорее всего ожидает хотя бы минимальный результат. Пусть не весь расчёт, но хоть предварительные данные без финального результата тоже интересны. И их бы тоже хотелось бы получить не смотря на то, что часть параметров пришла в негодность.
Хотелось бы иметь кэш валидации параметров, чтобы не запускать все эти формулы валидации каждый раз, когда потребовался параметр для расчёта в другой формуле.
dyadyaSerezha
27.07.2024 19:40+2Кэш валидации? Искать в словаре быстрее оператора сравнения??
AlexXYZ
27.07.2024 19:40П.2. Иногда проверка выполняется по формуле из нескольких параметров. Каждый раз при валидации запускать формулу или даже функцию быстрее, чем искать в словаре? Валидация параметров - это не только проверка значения на допустимый диапазон одного параметра.
Melanchall Автор
27.07.2024 19:40Спасибо, вы навели меня на мысль, дополнил раздел Минусы. Но, кажется, вы говорите о чём-то другом. Можете, пожалуйста, показать пример, где возникают сложности? Это я про ваш пункт 2.
dyadyaSerezha
27.07.2024 19:40+7"Invalid note number."
Всегда не любил такие ничего не говорящие сообшения об ошибке, потому что у пользователя библиотеки сразу возникает вопрос: блин, а какое число валидное?? Поэтому лучше чётко писать: должно быть между 0 и 127.
AlexXYZ
27.07.2024 19:40+1Ещё бы хорошо сопоставить с исходным кодом, а то хуже, чем ничего не говорящее сообщение об ошибке могут быть несколько одинаковых ничего не говорящих сообщений об ошибке.
Melanchall Автор
27.07.2024 19:40Согласен. В статье просто пример подхода. Хотя, признаюсь, грешу иногда такими малозначимыми сообщениями. Нужно будет пройтись по библиотеке проверить. Спасибо!
Sabirman
27.07.2024 19:40А какое сообщение в итоге увидит пользователь ?
Value is out of range for seven-bit number - совершенно негодится:
Во первых, не локализована,
Во вторых, нет названия парамнтра,
В третьих, должно быть обьяснение и курсор должнн стоять на неправильно заполненном поле
В четвертых, ваш алгоритм не проверяет все поля, а завершится на первом же неверном значении.
Melanchall Автор
27.07.2024 19:40Спасибо.
Статья не про локализацию, а про подход. Локализовывать здорово, но в библиотеке я этим не занимаюсь, считаю, что английского вполне хватит. Если честно, по пальцам пересчитать библиотеки, которые выводят локализованные сообщения об ошибках.
Да, это минус. С другой стороны, если вы получите сообщение с именем параметра, это всё равно ничего вам не скажет о том, почему в него попало невалидное значение.
Если речь про отладку и сложные выражения, да, есть такой момент, дописал в статью.
Верно. Чтобы сделать статью лучше, покажите, пожалуйста, пример, я с радостью добавлю.
UserSergeyB
27.07.2024 19:40Мне кажется, вы перепутали валидацию значений в UI и валидацию параметров метода в коде. В статье речь о втором, а не о первом.
Gromilo
27.07.2024 19:40Смотрел выхлоп компилятора для инта обёрнутого в структуру. В релизной версии никаких изменений, просто инт бегает по программе. Так что в каком-то смысле техника бесплатная.
В моём случае было так: из другой системы приходило количество товара, где 0 означало на заказа. В итоге в программе как-то незаметно стали плодиться проверки вида
x.Quantity == 0
. В какой-то момент решили, что хватит это терпеть, завернулись в структуру и сделали методIsOnOrder()
. А почему этот метод добавлен к количеству, а к тому где оно лежит? А потому что были алгоритмы, которые чисто с количеством работали.AlexXYZ
27.07.2024 19:40По хорошему должна быть параллельная структура данных, но с признаками валидностей или другими признаками к параметру, чтобы не вешать на значения параметров другую сущность.
Gromilo
27.07.2024 19:40Можно было и так, только у нас не было никаких других признаков. Просто знание специфическое именно для этого числа, сохранили прямо в число. Аля борьба с "одержимостью примитивами".
Worgen
27.07.2024 19:40А чем обычный FluentValidator не устраивает?
Melanchall Автор
27.07.2024 19:40+2Чем не устраивает, написано в следующем абзаце в статье:
Да, можно инструкции валидации упаковать в утилитные методы, можно даже подключить в проект стороннюю библиотеку с уже готовыми методами или использующую подход с добавлением на параметры особых атрибутов. Будем ли мы писать меньше кода? Вероятно. Избавит ли это нас от необходимости помнить о вставке специальных инструкций всякий раз, как мы имеем дело с сомнительными данными? Нет.
funny_falcon
27.07.2024 19:40Только сегодня наконец прочитал успевшую стать классикой “Parse, don’t Validate”, и тут ещё одна статья с таким же посылом.
mvv-rus
27.07.2024 19:40Что любопытно, за всё время работы в профессии я ни разу не встречался с таким приёмом.
Новое - это хорошо забытое старое. В Pascal (ещё в исходном, который от Вирта), например, были ограниченные типы.
Также не забывайте, что для значимых типов всегда нужно переопределять метод
Equals
, если вы не хотите на ровном месте иметь проблемы с производительностьюСейчас есть record struct, который это делает сам. Но, насколько я помню, ваша библиотека поддерживает (или поддерживала) старые версии C#, где этого не было.
Melanchall Автор
27.07.2024 19:40Да, библиотека поддерживает старые версии языка, но для полноты добавил информацию в статью, спасибо.
justmara
27.07.2024 19:40+1есть ещё другая история, лежащая несколько в параллельной плоскости - в проекте может быть необходимость иметь иерархический справочник, который в коде будет выглядеть чем-то типа `IDictionary<long, IDictionary<long, IList<long>>>` и умаешься везде вспоминать, что этот конкретный long значит. поэтому часто пользуемся вот этой либой и конструкция превращается в `IDictionary<PublisherId, IDictionary<AuthorId, IList<BookId>>>` и в любом методе аргумент вместо безликого long становится сразу понятным и строго типизованным.
VanKrock
27.07.2024 19:40Выбрасывание исключений в констукторе не самое ожидаемое поведение, неявное приведение типов хоть и выглядит интересно, но лучше его не использовать, так как от пользователя вашего класса требуется знать об этом поведении. Вместо этого можно воспользоваться проверенным способом, использовать методы 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"); } }
Melanchall Автор
27.07.2024 19:40Спасибо.
Внутри моей библиотеки у
SevenBitNumber
/FourBitNumber
есть методыParse
/TryParse
. Ну и в целом, исключение при приведении типа или исключение при валидации параметра сообщает нам об одном и том же — значение невалидно. В MS это не запрещается, просто преобразование должно быть явным:If a custom conversion can throw an exception or lose information, define it as an explicit conversion.
Касательно этого утверждения
неявное приведение типов хоть и выглядит интересно, но лучше его не использовать, так как от пользователя вашего класса требуется знать об этом поведении
не соглашусь. Как раз про неявное знать необязательно. На то оно и неявное, что никаких рисков нет. Из
SevenBitNumber
преобразование вbyte
не сопряжено ни с какими последствиями, поэтому оно неявное. Вероятно, вы имели в виду явное всё же.VanKrock
27.07.2024 19:40Но в конретом коде в статье это проблема, так как есть риск исключения при присваивании, что ну уж совсем не ожидаемо, всё таки метод Parse делает это прозрачным, что вот тут произойдёт преобразование одних данных в другие и есть некоторая логика этого преобразования, неявное преобразование тут подошло бы если бы не было бизнес логики, например у нас есть просто метод обёртка, который через констуктор поместит входные данные во внутреннее поле и всё, например это хорошо работает с Nullable. Ну и ещё аргумент против неявного привеления в том, что многие C# программисты, как и я, любят объявлять переменные через var и очень редко пишут тип вне объявленя параметров, свойств или полей
VanKrock
27.07.2024 19:40Ещё один момент в том, что вы преобразовываете число с большим количеством бит в число с меньшим количеством бит, то есть возможна потеря информации, в C# int можно преобразовать к long неявно, но вот long к int только явно
Melanchall Автор
27.07.2024 19:40Мне кажется, вы невнимательно прочли статью.
вы преобразовываете число с большим количеством бит в число с меньшим количеством бит, то есть возможна потеря информации
Всё верно, и такое преобразование у меня явное. Также, как
long
кint
. Посмотрите на код ещё раз.explicit operator
это оно. Вы же пытаетесь доказать, что у меня исключение в неявном преобразование, но это не так.
pankraty
27.07.2024 19:40Для 127 значений можно подумать в направлении enum-ов. Они дают практически все те же преимущества, но сверх того помогают не перепутать порядок, в котором аргументы передаются в метод (note, velocity или velocity, note), а также позволяют задать человекочитаемую семантику:
enum Pitch : byte { C0 = 0, /*Значения наобум, я понятия не имею, какое соответствие нот кодам*/ Csharp0 = 1, D0 = 2, ... }
Для Velocity польза сомнительна, конечно, но для нот - вполне удобно, мне кажется.
Melanchall Автор
27.07.2024 19:40Номер ноты и скорость нажатия лишь примеры, в MIDI намного больше таких сущностей.
Ну и
enum
не спасёт в случае вычисления значения по какой-то формуле. Придётся результат приводить к этомуenum
'у. А что если результат есть число, не принадлежащее перечисление? C# всё равно выполнит приведение, есть уenum
'ов такая особенность. И пойдёт гулять по программе невалидное значение.pankraty
27.07.2024 19:40Так я и не призываю все гвозди забивать одним молотком. Ваше решение хорошее и подходит для многих сценариев, но одновременно есть сценарии, где другие решения (например, те же enum-ы или record struct, как в SmartEnum) кажутся более уместными. Пользователю библиотеки знание о том, что какой-то тип занимаем 7 бит, мало что даёт, для него это чаще всего малосущественная деталь реализации. А вот гарантия на уровне типов, что переменная с номером канала не будет записана в поле, ожидающее ноту - это полезно, это удобно.
Что касается возможности получения невалидного значения в enum-е при совершении арифметических операций - так это для любого способа представления так. Контроль выхода за границы необходимо делать независимо от формата. И я не знаю, может быть пользователи библиотеки и правда активно складывают и умножают ноты, но более рациональным видится дать возможность прибавить вычесть интервал (терция, квинта, октава...) или найти разницу между двумя нотами в виде интервала. Такой сценарий опять же удобнее реализовывать через отдельные типы (можно провести аналогию с DateTime + TimeSpan), а не через единый универсальный SevenBitNumber.
Melanchall Автор
27.07.2024 19:40Да, соглашусь.
Вообще, в библиотеке касательно вашего подхода есть несколько методов, например:
public static Note Get(NoteName noteName, int octave)
или
public static SevenBitNumber GetNoteNumber(NoteName noteName, int octave)
Длиннее, чем
enum
, но идея та же, и она верная: по имени ноты и октаве получить ноту/её номер.
ColdPhoenix
На самом деле вместо
SevenBitNumber y = (SevenBitNumber)100;
можно сделать
SevenBitNumber y = new(100);
Dotarev
По сути, оба оператора используют преобразование константы в структуру во время выполнения. А теперь вопрос к джедаям: А можно каким-то образом перенести проверку на время компиляции?
Cregennan
Roslyn анализатор можно написать, который будет сборку блокировать если увидит неправильный каст
Melanchall Автор
Спасибо, добавил в статью. Правда, работать это будет только до тех пор, пока мы не захотим использовать слева
var
.