Амперы нельзя складывать с вольтами. Сантиметры можно складывать с дюймами, но очень внимательно. Иначе получится как с космическим аппаратом стоимостью 125 миллионов долларов Mars Climate Orbiter, который успешно долетел до Марса, но бездарно разбился о его поверхность.
Он разбился, поскольку разработчики его программного обеспечения не учли разницу используемых в разных частях системы физических единиц. По этой же самой причине до и после этой дорогой аварии взрывались и падали космические и летательные аппараты, тонули корабли и погибали люди.
Этих катастроф и смертей можно было бы избежать, если бы программисты бортового и системного ПО использовали в своей работе специализированные библиотеки типа KotUniL, о которой я хочу рассказать в этой серии статей.
Первая (эта) статья собственно о библиотеке, её возможностях и нехитрых правилах использования. Другие статьи этой серии затрагивают темы, которые могут оказаться полезными и интересными всем программистам, вне зависимости от используемого ими языка, хотя “котлинцам” они могут пригодиться больше других.
Вот полный список статей серии:
Магия размерностей и магия Котлина. Часть первая: Введение в KotUniL
Магия размерностей и магия Котлина. Часть вторая: Продвинутые возможности KotUniL
Магия размерностей и магия Котлина. Часть третья: Смешение магий
Как всё начиналось
В своём проекте я наткнулся на необходимость работы с формулами, использующими физические величины системы СИ, которые с одной стороны должны быть запрограммированы на Котлине, но с другой стороны должны быть понятны физикам.
По моему глубокому убеждению, библиотеки для работы с физическими величинами должны, наряду с библиотеками для работы с файлами, интернет-протоколами и т.п. принадлежать к числу стандартных библиотек, поставляемых разработчиками языка. Тем не менее, это не так. Что приводит к тому, что фирмы - “акулы энтерпрайза” за большие деньги сами их разрабатывают.
К сожалению, и мой любимый Котлин также поставляется без такой библиотеки. Свято место не бывает, и на GitHub можно найти несколько претендентов на эту роль. Поизучав их, я не нашёл ни одной библиотеки, удовлетворяющей моим требованиям. (Надеюсь, в своих поисках я не пропустил такую). Огорчённый этим обстоятельством, я решил написать собственную библиотеку.
Тем самым я нырнул в магию физических размерностей, а вынырнул в магии алгебры типов.
Далее в этой статье я вкратце рассажу о достигнутых результатах.
KotUniL
KotUniL (Kotlin Units Library) - это библиотека функций и объектов Kotlin, которые в целом отвечают следующим требованиям:
Охватывает все базовые единицы СИ, такие как метр, секунда и т.д., а также некоторые другие распространенные нефизические единицы, такие как валюты, процент и т.д.
Умеет аккуратно работать со всеми префиксами системы СИ - микро, нано, кило и т.д.
Позволяет записывать различные формулы на языке Kotlin способом, максимально похожим на то, как формулы записываются в физике и экономике.
Позволяет анализировать размерность результатов применения сложных формул.
Позволяет обнаружить большинство типичных ошибок при работе с единицами СИ уже на этапе компиляции. Ошибки некорректного использования физических единиц в сложных формулах выявляются во время выполнения, но могут быть легко обнаружены при обычном юнит-тестировании.
Это чистая библиотека (без плагина, без парсера и т.д.), не имеющая никаких зависимостей от сторонних библиотек.
Не очень понятно? Тогда давайте рассмотрим, как работает библиотека на ряде примеров, начиная с простейшего. (Не стану мудрить и приведу здесь несколько примеров из документации библиотеки на 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)
ExTalosDx
15.12.2022 09:43Вот это очень круто конечно. Когда абстракция выстроена так, что сама не допускает логических ошибок.
visirok Автор
15.12.2022 10:30Спасибо на добром слове.
Эти абстракции выстроила то ли сама природа, то ли пределущие поколения великих физиков и математиков, то ли они вместе. Именно это я и называю магией размерностей. Подробнее об этом я собираюсь рассказать в третей статье этой серии.
vldF
15.12.2022 11:48С точки зрения системы типов (как бы Вы не утверждали, что то, что получилось у Вас, лучше), было бы корректнее добавлять для каждой из таких единиц измерения свой тип. И тогда все такие ошибки ловились бы не при "пробегающем" юнит-тесте, а еще при компиляции. Чем раньше мы находим ошибку, тем лучше
visirok Автор
15.12.2022 12:21Спору нет, находить все ошибки при манипулировании с физическими и иными единицами уже на этапе компиляции было бы замечательно. Но увы, пока моя слабая фантазия не видит для этого другого способа кроме как создавать свой тип на каждую комбинацию размерностей - скорость, ускорение, плотность, и т.д. А их - безбрежное количество.
К тому же, это противоречит «логике физики», насколько я ее себе представляю.
Я размышляю сейчас над возможностью написания плагина на KSP для предпроцессинга кода с использованием KotUniL. Но пока, честно говоря, решения не нашёл. Да и по затратам это будет не так уж мало. А я тяну проект пока один в свободное от остальных забот время.
MEJIOMAH
15.12.2022 15:49Кодогенерация может помочь. Описываются основные величины а дальше декартовым произведением перемножаются на возможные операции.
visirok Автор
15.12.2022 16:22Не думаю, что это реально. Возьмём например скорость. Скорость движения - размерность m/s, скорость окраски забора - m2/s, скорость таяния льда - m3/s. Простое ускорение: m/s2, ускорение при окраске забора m/s2... и т.д. Это только простейшие комбинации двух размерностей. А много величин - комбинаций трёх, четырёх, пяти размерностей и т.д. Степени могут быть положительные и отрицательные. И главное - как упомнить названия получившихся классов? Уж тогда проще новый язык сделать. А мы хотим оставаться в Котлине.
transcengopher
15.12.2022 16:55+2В JSR 385 для Java именно что пошли по пути выделения размерностей. У них получились
Quantity<Volume>
,Unit<Length>
, и так далее. Разумеется, всех возможных комбинаций таким образом не покрыть, но множеству приложений этого делать и не придётся. При этом, стандартные размерности покрываются стандартным же образом, а при достаточно гибком дизайне библиотеки новые нужные для конкретно вашего приложения размерности добавить должно быть достаточно просто, и при этом не теряется типобезопасность.Описаний хороших найти что-то не получается, но вот статья на Baeldung про общий вид работы с библиотекой.
UPD: Ну как я раньше не подумал пойти на главный сайт и потыкаться там?
Ссылки: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 проверяются ровным одним юнит-тестом.
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
visirok Автор
16.12.2022 12:14Кроме длины в СИ есть ещё шесть других базовых размерностей. И 22 их распространнённых комбинации и сотни менее распространённых. И они могут сами далее комбинироваться.
Для каждой из этих комбинаций Вы предлагаете отдельный тип создавать?
speshuric
15.12.2022 14:06Прикольно, круто. Но вот к double (как я понял) просто гвоздями прибито - это будет стрелять по ногам.
visirok Автор
15.12.2022 14:50Это решение принято из прагматических соображений. А у Вас есть альтернативные предложения?
speshuric
15.12.2022 16:18Хотя бы для денег Double не использовать.
Сложно сказать, как вам правильно будет: зависит от перспектив использования. Если нацелены на JVM, то BigDecimal неплохой вариант для денег. Если будут другие таргеты (JS, Native, KMM) то сходу не скажу. Но в них вы даже с спецсимволами в бэктиках помучаетесь.visirok Автор
15.12.2022 16:27Классическая проблема с деньгами - какой числовой тип выбрать. В KotUniL - каждая национальная валюта - это свой тип. Об этом - во второй статье серии.
koperagen
Формулы и правда выглядят прикольно. А как с этим всем работает комплишен? Будет удобно нащелкать символ евро из предложенных вариантов на
5.
или проще учиться набирать юникод с клавиатуры?visirok Автор
Потерпите. Про использование валют в расчётах я напишу во второй части статьи.