20 Nov 2024
Текст больше про написание кода на Scala, чем про физику.
В физике есть понятие размерности. Например, килограммы нет смысла складывать с метрами или с секундами.
Если писать физические формулы и где-то ошибаться, то потом можно проверить размерности и найти часть ошибок.
Но при написании кода мы обычно возвращаемся в каменный век - скорее всего, там будут просто числа с плавающей точкой. Любые числа можно произвольно складывать, и не важно что в одной перемеенной были метры, а в другой - дюймы. Вся надежда на сознательность программиста, который может ошибаться.
Дальше я напишу, как можно Международную Систему Измерений (СИ) описать в системе типов в Scala 3.
В СИ есть базовые единицы измерения - метры, килограммы, секунды, градусы Кельвина, Амперы и т.п. Их перемножением и делением можно получить различные полезные сочетания. Например, скорость это метры делённые на секунды, а плотность - килограммы, делённые на кубические метры.
В принципе, если завести специальный тип “Метры” и все расстояния измерять в них, то проблему со сложением дюймов с сантиметрами можно будет избежать. Но таких типов будет много - длина, площадь, объём, скорость, ускорение, работа, мощность, вязкость … и писать руками каждый тип не хочется.
В примерах кода ограничусь тремя единицами - метрами, килограммами и секундами, остальное можно добавить по аналогии.
Ещё обращу внимание, что степени размерностей целые - например, метры могут быть в квадратными, кубическими, но не в степени 1.34.
object SI:
opaque type Value[M <: Int, KG <: Int, S <: Int] = Double
type Distance = SI.Value[1, 0, 0]
type Mass = SI.Value[0, 1, 0]
type Time = SI.Value[0, 0, 1]
Тип обозначен как непрозрачный (Opaque). С точки зрения байткода это будет просто Double, с точки зрения системы типов - это другой тип, никак не связанный с Double. Единственный мостик между ними - внутри объекта SI, где мы знаем, что Value[…] это просто Double и можем присваивать одно в другое.
Степень каждой единицы измерения - целое число. Дистанция, масса и время - наши базовые типы.
Сделаем конструкторы для типов:
object SI:
...
def zero[M <: Int, KG <: Int, S <: Int]: Value[M, KG, S] = 0.0
extension (v: Double)
def km: Distance = v * 1000.0
def m: Distance = v
def mm: Distance = v * 0.001
def kg: Mass = v
def g: Mass = v * 0.001
def second: Time = v
def minute: Time = v * 60.0
def ms: Time = v * 0.001
extension (v: Distance)
def asKm: Double = v * 0.001
def asM: Double = v
...
Итак, теперь мы можем создавать из Double наши типы и конвертировать их обратно в Double
import SI.*
val t: Time = 2.0.minute
val s: Distance = 123.0.mm
val sInMeters: Double = s.asM
В принципе вместо extension методов это могли бы быть просто функции, типа minute(2.0).asSeconds
Уже сейчас мы можем конвертировать метры в миллиметры. Возможно, не так впечатляет, как конвертировать между собой дюймы, мили и футы с ярдами, но там принцип точно такой же. Для времени и массы код пишется аналогично.
Теперь добавим сложение для переменных одной размерности
object SI:
...
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
def +(v2: Value[M, KG, S]): Value[M, KG, S] = v + v2
Ура, теперь метры можно складывать с метрами, а секунды с секундами, и перепутать не получится.
Теперь добавим умножение. При умножении степени, как известно, складываются, при делении вычитаются.
import scala.compiletime.ops.int.*
object SI:
...
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
def *[M2 <: Int, KG2 <: Int, S2 <: Int](v2: Value[M2, KG2, S2]): Value[M + M2, KG + KG2, S + S2] = v * v2
def /[M2 <: Int, KG2 <: Int, S2 <: Int](v2: Value[M2, KG2, S2]): Value[M - M2, KG - KG2, S - S2] = v / v2
M, KG и т.п. - всё целые числа, и мы из compiletime для них импортируем операции сложения, вычитания и т.п. Вся эта машинерия со сложением чисел и типами вида Value[a, b, c] будет на этапе компиляции, в рантайме останется только арифметическая операция с двумя Double.
В некоторых языках можно сделать числа Чёрча типа Zero, Next[Zero], Next[Next[Zero]]
и потом развлекаться с ними, но к счастью у нас всё удобно и параметром дженерика может быть обычный Int.
Сделаем операцию возведения в целую степень
import scala.compiletime.constValue
object SI:
...
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
inline def pow[T <: Int]: Value[M * T, KG * T, S * T] =
Math.pow(v, constValue[T])
Поскольку T это Int, constValue[T] превратит тип в значение. Например, constValue[42] = 42. Функция inline, чтобы было доступно constValue[T] - для каждого вызова оно может быть своё.
Теперь кубические метры можно записать так:
val cubeMeters = 1.0.m.pow[3]
Операцию нахождения корня их кубических метров предлагаю попробовать сделать самостоятельно.
Для отладки могут помочь функции превращения в строку
object SI:
...
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
inline def typeStr: String =
s"m^${constValue[M]}*kg^${constValue[KG]}*s^${constValue[S]}"
inline def toStr: String =
s"$v $typeStr"
Вот так относительно лаконично мы объяснили компилятору правила арифметических операции с размерными величинами.
Для удобства, само собой, можно дать красивые названия часто используемым типам:
object SI:
...
type Velocity = Value[1, 0, -1]
type Acceleration = Value[1, 0, -2]
Но сами типы тоже хочется как-то более красиво записать, попробуем напоследок сделать и это.
В этот раз типы надо объявить снаружи от объекта SI. Потому что внутри объекта SI тип Value[_, _, _] и Double это одно и то же, и компилятор будет ругаться на паттерн матчинг по типам.
Я им некрасивые имена (не +, *, и т.п.), чтобы не пересекались с теми что импортируются из scala.compiletime.ops.int
type SiDiv[V1, V2] =
(V1, V2) match {
case (SI.Value[m, kg, s], SI.Value[m2, kg2, s2]) => SI.Value[m - m2, kg - kg2, s - s2]
}
type SiMul[V1, V2] =
(V1, V2) match {
case (SI.Value[m, kg, s], SI.Value[m2, kg2, s2]) => SI.Value[m + m2, kg + kg2, s + s2]
}
Теперь можно писать так:
type Velocity = SiDiv[SI.Distance, SI.Time]
type Acceleration = SiDiv[SI.Velocity, SiMul[SI.Time, SI.Time]]
В принице, наверно можно и красивые имена им дать, тогда читаемость будет чуть повыше. Но это больше вопрос наименований, суть не поменяется.
type Velocity = Distance / Time
type Acceleration = Distance / (Time * Time)
Весь код:
import scala.compiletime.constValue
import scala.compiletime.ops.int.*
type SiDiv[V1, V2] =
(V1, V2) match {
case (SI.Value[m, kg, s], SI.Value[m2, kg2, s2]) => SI.Value[m - m2, kg - kg2, s - s2]
}
type SiMul[V1, V2] =
(V1, V2) match {
case (SI.Value[m, kg, s], SI.Value[m2, kg2, s2]) => SI.Value[m + m2, kg + kg2, s + s2]
}
object SI:
opaque type Value[M <: Int, KG <: Int, S <: Int] = Double
type Distance = Value[1, 0, 0]
type Mass = Value[0, 1, 0]
type Time = Value[0, 0, 1]
type Velocity = SiDiv[SI.Distance, SI.Time]
type Acceleration = SiDiv[SI.Velocity, SiMul[SI.Time, SI.Time]]
type Area = SiMul[SI.Distance, SI.Distance]
def zero[M <: Int, KG <: Int, S <: Int]: Value[M, KG, S] =
0.0
extension (v: Double)
def km: Distance = v * 1000.0
def m: Distance = v
def cm: Distance = v * 0.01
def mm: Distance = v * 0.001
def kg: Mass = v
def g: Mass = v * 0.001
def second: Time = v
def minute: Time = v * 60.0
def ms: Time = v * 0.001
extension (v: Velocity)
def kmh: Double = v * 3.6
extension (v: Distance)
def asKm: Double = v * 0.001
def asM: Double = v
def asCm: Double = v * 100.0
def asMm: Double = v * 1000.0
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
def +(v2: Value[M, KG, S]): Value[M, KG, S] =
v + v2
def square: Value[M * 2, KG * 2, S * 2] =
v * v
inline def pow[T <: Int]: Value[M * T, KG * T, S * T] =
Math.pow(v, constValue[T])
inline def typeStr: String =
s"m^${constValue[M]}*kg^${constValue[KG]}*s^${constValue[S]}"
inline def toStr: String =
s"${v} ${typeStr}"
extension [M <: Int, KG <: Int, S <: Int](v: Value[M, KG, S])
def *[M2 <: Int, KG2 <: Int, S2 <: Int](v2: Value[M2, KG2, S2]): Value[M + M2, KG + KG2, S + S2] = v * v2
def /[M2 <: Int, KG2 <: Int, S2 <: Int](v2: Value[M2, KG2, S2]): Value[M - M2, KG - KG2, S - S2] = v / v2
И пример использования
import SI.*
@main
def main(): Unit = {
val t = 10.minute
val s = 10.km
val velocity = s / t
println(velocity.toStr)
val acceleration = 9.8.m / 1.second.square
println(acceleration.toStr)
println(1.0.m.pow[3].toStr)
}
На момент написания кода использовалась Scala 3.5.2
P.S. Если посмотреть сгенерированный байткод - для хранения значений используется Double. Но, к сожалению, вызовы ничего не делающих функций типа 1.0.m.asM всё равно остаются. Я попробовал добавить слово inline к ним всем, но компилятор скалы всё равно зачем-то оставляет обращения к объекту SI. Остаётся надеяться, что при JIT компиляции тривиальные функции будут убраны.