Mars Climate Orbiter
Mars Climate Orbiter

Амперы нельзя складывать с вольтами. Сантиметры можно складывать с дюймами, но очень внимательно. Иначе получится как с космическим аппаратом стоимостью 125 миллионов долларов Mars Climate Orbiter, который успешно долетел до Марса, но бездарно разбился о его поверхность. 

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

Этих катастроф и смертей можно было бы избежать, если бы программисты бортового и системного ПО использовали в своей работе специализированные библиотеки типа KotUniL, о которой я хочу рассказать в этой серии статей. 

Первая (эта) статья собственно о библиотеке, её возможностях и нехитрых правилах использования. Другие статьи этой серии затрагивают темы, которые могут оказаться полезными и интересными всем программистам, вне зависимости от используемого ими языка, хотя “котлинцам” они могут пригодиться больше других.

Вот полный список статей серии:

  1. Магия размерностей и магия Котлина. Часть первая: Введение в KotUniL  

  2. Магия размерностей и магия Котлина. Часть вторая: Продвинутые возможности  KotUniL

  3. Магия размерностей и магия Котлина. Часть третья: Смешение магий

Как всё начиналось

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

По моему глубокому убеждению, библиотеки для работы с физическими величинами должны, наряду с библиотеками для работы с файлами, интернет-протоколами и т.п. принадлежать к числу стандартных библиотек, поставляемых разработчиками языка. Тем не менее, это не так. Что приводит к тому, что фирмы - “акулы энтерпрайза” за большие деньги сами их разрабатывают. 

К сожалению, и мой любимый Котлин также поставляется без такой библиотеки. Свято место не бывает, и на GitHub можно найти несколько претендентов на эту роль. Поизучав их, я не нашёл ни одной библиотеки, удовлетворяющей моим требованиям. (Надеюсь, в своих поисках я не пропустил такую). Огорчённый этим обстоятельством, я решил написать собственную библиотеку.

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

Далее в этой статье я вкратце рассажу о достигнутых результатах. 

KotUniL

KotUniL (Kotlin Units Library) - это библиотека функций и объектов Kotlin, которые в целом отвечают следующим требованиям:

  1. Охватывает все базовые единицы СИ, такие как метр, секунда и т.д., а также некоторые другие распространенные нефизические единицы, такие как валюты, процент и т.д.

  2. Умеет аккуратно работать со всеми префиксами системы СИ - микро, нано, кило и т.д.

  3. Позволяет записывать различные формулы на языке Kotlin способом, максимально похожим на то, как формулы записываются в физике и экономике.

  4. Позволяет анализировать размерность результатов применения сложных формул.

  5. Позволяет обнаружить большинство типичных ошибок при работе с единицами СИ уже на этапе компиляции. Ошибки некорректного использования физических единиц в сложных формулах выявляются во время выполнения, но могут быть легко обнаружены при обычном юнит-тестировании.

  6. Это чистая библиотека (без плагина, без парсера и т.д.), не имеющая никаких зависимостей от сторонних библиотек.

Не очень понятно? Тогда давайте рассмотрим, как работает библиотека на ряде примеров, начиная с простейшего. (Не стану мудрить и приведу здесь несколько примеров из документации библиотеки на GitHub).

Маша мыла... аквариум

Рассмотрим первый пример.

Маша протирала снаружи стекло аквариума, задела стоявшую рядом вазу, в результате чего стекло аквариума разбилось и вода вытекла на пол. В аквариуме до этой неприятности было 32 литра воды. Комната Маши имеет длину 4 метра и ширину 4,3 метра. На какой высоте в мм. находится сейчас вода в комнате, при условии, что она осталась там и не вытекла?

Решение на языке Kotlin/KotUniL может быть записано одной строкой. В дидактических целях введем две вспомогательные переменные s и h для площади комнаты и уровня воды в комнате.

val s = 4.m * 4.3.m
val h = 32.l/s   
print(«Высота воды в комнате ${h.mm} mm."

Приглядимся повнимательнее. Площадь комнаты измеряется в квадратных метрах. Переменная s в результате перемножения метров на метры получила  неявным образом эту размерность. А литр - это тысячная часть кубического метра. Кубические метры, поделённые на квадратные метры дают в итоге просто метры (переменная h). Но нам хочется перевести их миллиметры, что мы и делаем при печати.

Это больше чем type safety

“А причём тут разбившийся о поверхность Марса космический аппарат?” - возможно спросит кто-то из читателей.

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

//val x = 1.m + 2 ошибка компиляции
//val y = 20.l/(4.m * 5.m) + 14 ошибка компиляции

//Более заковыристые ошибки выявляются в runtime:
val exception = assertFailsWith<IllegalArgumentException>(
  block = { 1.m + 2.s }
)
assertTrue(exception.message!!.startsWith(COMPATIBILITY_ERR_PREFIX))

Я хочу особенно подчеркнуть эту особенность библиотеки: если  ваша формула некорректна, то вне зависимости от используемых значений физических величин любой “пробежавший” по ней юнит-тест покажет её некорректность. Почему это так, я постараюсь показать в следующей статье этой серии. 

Пока же обратим внимание на то, что описанная фича библиотеки это больше чем классический type safety. Речь здесь идёт не только о корректности самих единиц, но и результатов, вычисленных с помощью арифметических формул произвольной сложности. 

Сравнение сложных объектов

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

Как и в случае со сложением и вычитанием, сравнивать можно только объекты одного типа:

 assertTrue(5.m > 4.1.m)
 assertTrue(20.2*m3 > 4.2*m3)
 assertTrue(2.2*kg*m/s < 4.2*kg*m/s)

При попытке сравнивать объекты разных типов библиотекой будет выброшено IllegalArgumentException

 val v1 = 2.4.m
 val v2 = 2.4.s
 val exception = assertFailsWith<IllegalArgumentException>(
   block = { v1 >= v2 }
 )
 assertTrue(exception.message!!.startsWith(COMPATIBILITY_ERR_PREFIX))

  или:

 val v1 = 2.4.m*kg/s
 val v2 = 2.4.s*m3/μV
 val exception = assertFailsWith<IllegalArgumentException>(
   block = { v1 >= v2 }
 )
 assertTrue(exception.message!!.startsWith(COMPATIBILITY_ERR_PREFIX))

 Если вас заинтересовало, что означает μV - это микровольты. Но про них и прочие префиксные выражения в рамках системы СИ и KotUniL мы поговорим в следующей статье серии.

Анализ размерностей

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

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

В системе СИ для каждой физической единицы задано его имя (например метр, или m) и категория (в случае метра - это длина, или L). 

Функция unitSymbols() показывает символы размеренности и их степени:

 val s = 4.m * 5.m
 assertEquals("m2", s.unitSymbols())

 val x = 20.l
 assertEquals("m3", x.unitSymbols())

 val h = x/s
 assertEquals("m", h.unitSymbols())

 val y = 1.2.s
 assertEquals("s", y.unitSymbols())

 val z = x/y
 assertEquals("m3/s", z.unitSymbols())

А функция categorySymbols() -  примерно тоже самое для категорий и несколько в другом виде::

 val s = 4.m * 5.m
 assertEquals("L2", s.categorySymbols())

 val x = 20.l
 assertEquals("L3", x.categorySymbols())

 val h = x/s
 assertEquals("L", h.categorySymbols())

 val y = 1.2.s
 assertEquals("T", y.categorySymbols())

 val z = x/y
 assertEquals("L3T-1", z.categorySymbols())

А как использовать?

Как уже говорилось выше, KotUniL - это библиотека Котлина без каких-либо внешних зависимостей. Поэтому её очень просто подключить к вашему Котлин-проекту. В случае gtadle/KTS это делается добавлением в ваш build.gradle.kts  строк:

repositories {
   mavenCentral()
 }

 dependencies {
   implementation("eu.sirotin.kotunil:kotunil:1.0.1")
 }

Аналогичные зависимости вам нужно добавить в  pom-файл в случае использования Maven:

    <dependency>

        <groupId>eu.sirotin.kotunil</groupId>

        <artifactId>kotunil</artifactId>

        <version>1.0.1</version>

    </dependency>

Ну а исходные коды библиотеки вы найдёте на GitHub: https://github.com/vsirotin/si-units 

Если вы при этом добавите проекту звёздочку, автор в обиде не будет :-)

На этом мы закончим знакомство с основными возможностями библиотеки. В следующей статье этой серии мы познакомимся с её “продвинутыми” фичам.


Иллюстрация: Космический аппарат  Mars Climate Orbiter разбившийся по вине программистов, неправильно работавших с физическими размерностями. Источник: Wikipedia

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


  1. koperagen
    15.12.2022 03:23

    Формулы и правда выглядят прикольно. А как с этим всем работает комплишен? Будет удобно нащелкать символ евро из предложенных вариантов на 5. или проще учиться набирать юникод с клавиатуры?


    1. visirok Автор
      15.12.2022 10:32

      Потерпите. Про использование валют в расчётах я напишу во второй части статьи.


  1. ExTalosDx
    15.12.2022 09:43

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


    1. visirok Автор
      15.12.2022 10:30

      Спасибо на добром слове.

      Эти абстракции выстроила то ли сама природа, то ли пределущие поколения великих физиков и математиков, то ли они вместе. Именно это я и называю магией размерностей. Подробнее об этом я собираюсь рассказать в третей статье этой серии.


  1. vldF
    15.12.2022 11:48

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


    1. visirok Автор
      15.12.2022 12:21

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

      К тому же, это противоречит «логике физики», насколько я ее себе представляю.

      Я размышляю сейчас над возможностью написания плагина на KSP для предпроцессинга кода с использованием KotUniL. Но пока, честно говоря, решения не нашёл. Да и по затратам это будет не так уж мало. А я тяну проект пока один в свободное от остальных забот время.


      1. MEJIOMAH
        15.12.2022 15:49

        Кодогенерация может помочь. Описываются основные величины а дальше декартовым произведением перемножаются на возможные операции.


        1. visirok Автор
          15.12.2022 16:22

          Не думаю, что это реально. Возьмём например скорость. Скорость движения - размерность m/s, скорость окраски забора - m2/s, скорость таяния льда - m3/s. Простое ускорение: m/s2, ускорение при окраске забора m/s2... и т.д. Это только простейшие комбинации двух размерностей. А много величин - комбинаций трёх, четырёх, пяти размерностей и т.д. Степени могут быть положительные и отрицательные. И главное - как упомнить названия получившихся классов? Уж тогда проще новый язык сделать. А мы хотим оставаться в Котлине.


          1. MEJIOMAH
            15.12.2022 17:39

            Да, так и есть.
            val operations = {умножение/деление}
            var units = intialUnits
            while(makeSense){
            units = units*operations*units
            }


            1. visirok Автор
              15.12.2022 22:52

              Это фрагмент кодогенератора?


      1. transcengopher
        15.12.2022 16:55
        +2

        В JSR 385 для Java именно что пошли по пути выделения размерностей. У них получились Quantity<Volume>, Unit<Length>, и так далее. Разумеется, всех возможных комбинаций таким образом не покрыть, но множеству приложений этого делать и не придётся. При этом, стандартные размерности покрываются стандартным же образом, а при достаточно гибком дизайне библиотеки новые нужные для конкретно вашего приложения размерности добавить должно быть достаточно просто, и при этом не теряется типобезопасность.


        Описаний хороших найти что-то не получается, но вот статья на Baeldung про общий вид работы с библиотекой.


        UPD: Ну как я раньше не подумал пойти на главный сайт и потыкаться там?
        Ссылки:



        1. visirok Автор
          15.12.2022 22:50

          Я посмотрел примеры в рекомендованной Вами статье. Это мазохизм создателей и садизм по отношению к потенциальным пользователям. Чего стоят подобный пример конвертирования метров в километры, взятый мной из рекомендованной Вами статьи:

          double distanceInMeters = 50.0;
          UnitConverter metreToKilometre = METRE.getConverterTo(MetricPrefix.KILO(METRE));
          double distanceInKilometers = metreToKilometre.convert(distanceInMeters );

          Используя KotUniL вы это запишите так:

          val d = 50.m
          val x = d.km

          Я понимаю, вы говорите про обнаружение ошибок на уровне компиляции, а я про наглядность и краткость кода.

          В третьей статье этой серии я постараюсь показать, что любые формулы KotUniL проверяются ровным одним юнит-тестом.


          1. transcengopher
            16.12.2022 10:31

            Ничто на самом деле не мешает разработчикам JSR 385 сделать дополнительный метод


            public Quantity<Q> convertTo(Unit<Q> quantity);

            И тогда то, что вам не понравилось, будет выглядеть как-то так:


            Quantity<Length> distance = getQuantity(2500d, METRE);
            Quantity<Length> inKilometers = distance.convertTo(KILO(METRE));

            А это, согласитесь, уже примерно то же, что и у вас, с поправкой на отсутствие в Java таких же возможностей по добавлению синтаксического сахара. И тайпчекается на ура.


            Я же не предлагаю вам полностью повторить тот API. Там на мой взгляд многовато всяческих "мин" подложено — да хотя бы тот факт, что у них Unit параметризуется через Quantity, хотя по всем моим инстинктам они оба должны быть параметризованы через Dimension.


            UPD: Собственно, там уже есть такой метод, как я предложил: Quantity::to


            1. visirok Автор
              16.12.2022 12:14

              Кроме длины в СИ есть ещё шесть других базовых размерностей. И 22 их распространнённых комбинации и сотни менее распространённых. И они могут сами далее комбинироваться.

              Для каждой из этих комбинаций Вы предлагаете отдельный тип создавать?


  1. speshuric
    15.12.2022 14:06

    Прикольно, круто. Но вот к double (как я понял) просто гвоздями прибито - это будет стрелять по ногам.


    1. visirok Автор
      15.12.2022 14:50

      Это решение принято из прагматических соображений. А у Вас есть альтернативные предложения?


      1. MEJIOMAH
        15.12.2022 16:04

        Много их. Посмотрите в сторону kmath


      1. speshuric
        15.12.2022 16:18

        Хотя бы для денег Double не использовать.
        Сложно сказать, как вам правильно будет: зависит от перспектив использования. Если нацелены на JVM, то BigDecimal неплохой вариант для денег. Если будут другие таргеты (JS, Native, KMM) то сходу не скажу. Но в них вы даже с спецсимволами в бэктиках помучаетесь.


        1. visirok Автор
          15.12.2022 16:27

          Классическая проблема с деньгами - какой числовой тип выбрать. В KotUniL - каждая национальная валюта - это свой тип. Об этом - во второй статье серии.