Привет!

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

Для нетерпеливых: github.

Пример работы
Пример работы

Есть несколько существующих решений для ЕИ, например, UnitsNet и Units of Measure in F#. Оба решения популярны и выполняют свою работу. Но мы здесь будет делать полностью расширяемую систему. А еще мы хотим автоматическую конвертацию ЕИ.

Итак, погнали.

Реализация

Основной принцип в том, что мы никак не делим ЕИ на физические величины. У нас нет длин, дистанций, времени, массы, площади, и т. д. Но при этом у каждой ЕИ есть базовая ЕИ и значение.

У ЕИ может быть любая базовая ЕИ. Для простоты я буду брать СИ как базовые ЕИ. Например, для километра базовой ЕИ будет метр (1000 метров в километре). Для грамма - килограмм (0.001 кг в г). Для метра базовая ЕИ - тоже метр (1:1).

Вот так выглядит интерфейс, который реализуется каждой ЕИ:

public interface IBaseUnit<T, TNumber>
{
    TNumber Base { get; }
    string Postfix { get; }
}

Base - количество базовой ЕИ в нашей. Postfix - просто текстовый эквивалент. Например, так определена минута:

public struct Minute<TNumber> : IBaseUnit<Second<TNumber>, TNumber>
		where TNumber : IMultiplicativeIdentity<TNumber, TNumber>, IParseable<TNumber>
{
    public string Postfix => "min";
    public TNumber Base => Constants<TNumber>.Number60;
}

TNumber нужен для generic math.

Итак, что насчет арифметических операций? На самом деле для них тоже есть свои единицы измерения. Например, вот так определено деление:

public struct Div<T1, T2, T1Base, T2Base, TNumber>
    : IBaseUnit<Div<T1Base, T2Base, T1Base, T2Base, TNumber>, TNumber>
    where T1Base : struct, IBaseUnit<T1Base, TNumber>
    where T2Base : struct, IBaseUnit<T2Base, TNumber>
    where T1 : struct, IBaseUnit<T1Base, TNumber>
    where T2 : struct, IBaseUnit<T2Base, TNumber>
    where TNumber : IDivisionOperators<TNumber, TNumber, TNumber>
{
    public TNumber Base => new T1().Base / new T2().Base;
    public string Postfix => $"({new T1().Postfix}/{new T2().Postfix})";
}

Немного жирноватое определение, но не в том суть. Div так же реализует IBaseUnit интерфейс, причем базовая ЕИ для него - это деление базовых ЕИ числителя и знаменателя. Например, для ЕИ км/мин базовая ЕИ - м/с.

Так как такая система не зависит от самих единиц и физических величин, мы можем легко создать метод, который конвертирует что угодно в что угодно при условии, что базовая ЕИ совпадает:

Конвертация единиц измерения с общей базовой ЕИ
Конвертация единиц измерения с общей базовой ЕИ

Т. е. мы просто требуем одну и ту же базовую ЕИ, и отталкиваясь от нее конвертирует любую в любую. А если базовая ЕИ не совпадает, значит нельзя конвертировать!

Конвертация из метров в секунды невозможна
Конвертация из метров в секунды невозможна

Подобным способом, требуя одну базовую ЕИ, мы можем реализовать сложение. К сожалению, оператор + не получится определить, так как у нас не может быть generic оператор. Поэтому я сделал его методом расширения (extension method):

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Unit<T1, TBase, TNumber> 
    Add<T1, T2, TBase, TNumber>(this Unit<T1, TBase, TNumber> a, Unit<T2, TBase, TNumber> b)
    where T1 : IBaseUnit<TBase, TNumber>
    where T2 : IBaseUnit<TBase, TNumber>
    // убрал несколько constraint-ов для облегчения чтения
    => 
        typeof(T1) == typeof(T2)
        ? new(a.Float + b.Float)
        : new((a.Float * new T1().Base + b.Float * new T2().Base) / new T1().Base);

Такой метод автоматически конвертирует методы с одинаковой базовой ЕИ даже если сами ЕИ разные. Например, 20 секунд + 1 минута = 80 секунд. 1 км + 1 миля = 2.6 км. Но попытка сложить секунды и метры не удастся (не скомпилируется).

Пришло время демонстрации результат работы.

Примеры работы

Все подряд:

Большой пример работы
Большой пример работы

В отличии от C#, в F# есть generic операторы, почему бы их не попробовать?

Пример работы библиотеки в F#
Пример работы библиотеки в F#

Как мы помним, все делалось так, чтобы работала generic math. То есть мы можем подставить любой тип, который реализует необходимые интерфейсы. Например, мы можем взять AngouriMath.Experimental, экспериментальная версия AngouriMath, которая реализует интерфейсы generic math.

Пример работы символьной алгебры с нашей системой ЕИ
Пример работы символьной алгебры с нашей системой ЕИ

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

Не слишком плохо. На самом деле единственный оверхед нашей системы в том, что JIT не промоутит структуры с единственным полем пока что. Поэтому если с float-ами мы передаем из через xmm регистры, то здесь приходится сначала записать значение юнита в память, потом выгрузить на xmm, произвести операцию, и обратно. Тем не менее, быстрее с оберточным типом сделать невозможно, да и потерянное время - это порядок долей наносекунды для одной операции. Больше информации.

Вывод

Вовсе не могу сказать, что это что-то объективно лучшее чем то, что существует. Но как концепт чего-то светлого очень даже. Вот таблица, которая сравнивает мою систему ЕИ, такую у F# и UnitsNet.

Ext. это про расширяемость физических величин и единиц измерения. Таблица здесь.

Гитхаб репозитория и мой гитхаб. Эта же статья на английском.

Спасибо за внимание. Задавайте вопросы, оставляйте фидбек!

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


  1. OlegZH
    23.12.2021 14:28

    Версия VS играет значение? На какой версии можно попробовать?


    1. WhiteBlackGoose Автор
      23.12.2021 14:33

      Ну на VS 22 точно будет работать. Про VS 19 честно - не знаю, не пробовал.

      Если что, есть dotnet CLI.


  1. propell-ant
    23.12.2021 14:58
    +1

    Я, когда делал подобное, реализовал операцию умножения для кратных приставок (кило, милли). Тогда объявляются классы базовых единиц измерения, а кратные записываются как
    1 * k * m.


  1. lam0x86
    23.12.2021 15:23

    Кажется, используя это, можно добиться дженерик-переопределения для операторов (сам я ещё не пробовал эту фичу).


    1. WhiteBlackGoose Автор
      23.12.2021 15:26

      Нельзя. Проблема в том, что нам нужен дженерик оператор, а не оператор на дженериках. Т. е. что-то типа

      static TNew operator +<TNew>(TOld a, TNew b)


      1. DARKShadow
        23.12.2021 17:57

        1. WhiteBlackGoose Автор
          23.12.2021 17:59

          Это приватная реализация, или как она там называется, а не дженерик оператор.


          1. DARKShadow
            23.12.2021 18:28

            1. WhiteBlackGoose Автор
              23.12.2021 18:32

              Я же говорю - нет. В твоем коде + все равно НЕ дженерик оператор. Интерфейс никак ни на что не влияет. Интерфейс нужен для того, чтобы уметь ограничить тип до того, у которого есть оператор. А мне нужен дженерик оператор. А его нет. Совсем нет.


  1. fkafka
    23.12.2021 15:45
    +1

    Довольно прикольно, но ведь, если я не ошибаюсь, generic math основано на abstract statics, а они в релиз C# 10 так и не вошли, или нет?


    1. WhiteBlackGoose Автор
      23.12.2021 17:42

      Верно, это до сих пор preview фича


      1. fkafka
        23.12.2021 18:26

        А сработает, если я, скажем, 1 час поделю на 12 минут, или 60 км/ч умножу на полдня? Потому что это было бы реально прикольно.


        1. WhiteBlackGoose Автор
          23.12.2021 18:37

          Сработает, конечно. 1 час / 12 минут будет 0.08 час/мин :).

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


          1. fkafka
            24.12.2021 01:51
            +1

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

            Допустим, тело начинает двигаться с ускорением A и надо узнать какое расстояние L оно пройдет за время T. Записываем это в таком виде: L = A^n * T^m. Теперь, зная, что (возьмем систему СГС), расстояние это "см", ускорение это "см / сек^2", а время это "сек" переписываем это уравнение так: см = (см / сек^2)^n * сек^m, сокращаем степени: см = см^n * сек^(m - 2n). А значит n = 1, а m = 2 и искомая формула: L = A * T^2.

            В итоге мы совершенно ничего не зная про какое-либо интегральное или дифференциальное исчисление получили хоть и неправильную формулу (правильно еще поделить на два), но с совершенно правильной асимптотикой, т.е. искомое расстояние линейно зависит от ускорения и квадратично от времени. Профит :)


  1. zVadim
    24.12.2021 08:50
    +2

    Достаточно долго программирую на C# в Enterprise. Успел проникнутся идеей DDD о строгой типизацией бизнес-сущностей, и пытаюсь её использовать там, где это уместно. Но, сталкиваясь только с днями, рублями и квадратными метрами, как то вообще упустил из виду, что вокруг есть целый физический мир. И в нем есть единицы измерения, которые тоже можно типизировать. И не в контексте бизнес-требований, а просто сами по себе. А там и производные единицы, и действия над ними, и приведение одних к другим...

    Огромное спасибо за статью! Хожу довольный как ребёнок, который узнал что-то новое)