火星上的生活:度量单位系统、Groovy 和领域特定语言 (DSL)
作者:Paul King
发布日期:2022-08-13 06:31AM
度量单位系统
所有编程语言都有用于表示数字的类型。例如,我们可能有三个整数,一个表示高度,一个表示重量,一个表示温度。我们可以编写代码将这三个整数加在一起,但结果可能没有有用的意义。我们可以开始为每种整数类型编写自己的类层次结构,但使用结果类很快就会变得很麻烦。度量单位系统试图提供一系列常见单位、从这些单位构建数量的方法以及操作它们的方法。操作可能涉及执行数值计算或转换。此类系统的目标包括能够提供运行时和/或编译时类型安全。因此,当我们尝试执行前面提到的三个不相关数量的加法时,我们应该尽早失败。
度量单位系统并不新鲜。F# 和 Swift 中存在现有系统,Java 已经围绕标准化此类系统进行了多个版本(以及更早的尝试),包括
JSR 385:度量单位 API 2.0
implementation 'tech.units:indriya:2.1.3'
JSR 385 是可扩展的。我们还将从 Unicode CLDR 单位中引入一些单位,例如 `MILE`
implementation 'systems.uom:systems-unicode:2.1'
implementation 'systems.uom:systems-quantity:2.1'
让我们继续以访问火星为主题。我们可以创建表示火星质量和直径的变量,如下所示
var massₘ = Quantities.getQuantity(6.39E23, KILO(GRAM))
var diameterₘ = Quantities.getQuantity(6_779, KILOMETER)
println massₘ
println diameterₘ
JSR 385 具有公制前缀限定符,如 MICRO
、MILLI
、CENTI
、KILO
、MEGA
、GIGA
等等。CLDR 单位还定义了一些常用单位,如 KILOMETER
。我们可以在这里选择任一单位。
当我们运行此脚本时,它将输出以下内容
639000000000000000000000 kg 6779 km
如果我们尝试比较或添加这两个值,我们将看到错误。Groovy 具有静态和动态特性。使用像这样的动态代码
println massₘ > diameterₘ
我们将看到这样的运行时错误
javax.measure.IncommensurableException: km is not compatible with kg
或者,在 TypeChecked
或 CompileStatic
针对像这样的语句生效的情况下
println massₘ.add(diameterₘ)
我们将看到这样的编译时错误
[Static type checking] - Cannot call tech.units.indriya.ComparableQuantity#add(javax.measure.Quantity)
with arguments [tech.units.indriya.ComparableQuantity]
如果由于某种奇怪的原因,我们确实希望比较或在不可通约类型之间执行计算,我们可以显式获取值
assert massₘ.value > diameterₘ.value
这种逃避机制取消了一层类型安全,但需要显式工作才能做到这一点。通常情况下,你永远不会想要这样做。
JSR 385 还支持范围和转换。我们可以查看火星上的最低和最高温度
var minTemp = Quantities.getQuantity(-128, CELSIUS)
var maxTemp = Quantities.getQuantity(70, FAHRENHEIT)
println minTemp
println minTemp.to(FAHRENHEIT)
println maxTemp
println maxTemp.to(CELSIUS)
println QuantityRange.of(minTemp, maxTemp)
这比地球冷很多!运行时,此脚本将输出以下内容
-128 ℃ -198.400 ((K*5)/9)+459.67 70 ((K*5)/9)+459.67 21.1111111111111111111111111111111 ℃ min= -128 ℃, max= 70 ((K*5)/9)+459.67
如果你想知道华氏温度的奇怪单位显示,我们正在使用的该单位的定义使用一个公式来定义华氏度,该公式是根据开尔文温度计算得出的。
如果使用 MILE
单位,我们将看到相同的结果
println diameterₘ.to(MILE)
这表明火星的直径略大于 4200 英里
4212.275312176886980036586335798934 (m*1609344)/1000
添加一些元编程
Groovy 具有各种功能,允许将方法(表面上)添加到类中。我们将使用扩展方法。此技术涉及在辅助类中使用某些约定编写静态方法。所有此类方法的第一个参数是扩展的目标。引用目标类实例的 Groovy 代码具有可以调用此类方法的代码,就好像它存在于目标类中一样。实际上,Groovy 编译器或运行时通过辅助类传递调用。对我们来说,这意味着我们将拥有 Number
类上的 getMeters()
等方法,使用 Groovy 对属性表示法的简写,允许非常简洁的数量定义,如 5.meters
。我们还将添加一些方法,以允许 Groovy 的正常运算符重载语法适用
class UomExtensions {
static Quantity<Length> getCentimeters(Number num) { Quantities.getQuantity(num, CENTI(METRE)) }
static Quantity<Length> getMeters(Number num) { Quantities.getQuantity(num, METRE) }
static Quantity<Length> getKilometers(Number num) { Quantities.getQuantity(num, KILO(METRE)) }
static Quantity<Length> getCm(Number num) { getCentimeters(num) }
static Quantity<Length> getM(Number num) { getMeters(num) }
static Quantity<Length> getKm(Number num) { getKilometers(num) }
static Quantity<Mass> getKilograms(Number num) { Quantities.getQuantity(num, KILO(GRAM)) }
static Quantity<Mass> getKgs(Number num) { getKilograms(num) }
static Quantity<Time> getHours(Number num) { Quantities.getQuantity(num, HOUR) }
static Quantity<Time> getSeconds(Number num) { Quantities.getQuantity(num, SECOND) }
static Quantity<Time> getHr(Number num) { getHours(num) }
static Quantity<Time> getS(Number num) { getSeconds(num) }
static Quantity<Speed> div(Quantity<Length> q, Quantity<Time> divisor) { q.divide(divisor) as Quantity<Speed> }
static <Q> Quantity<Q> div(Quantity<Q> q, Number divisor) { q.divide(divisor) }
static <Q> Quantity<Q> plus(Quantity<Q> q, Quantity<Q> divisor) { q.add(divisor) }
static <Q> Quantity<Q> minus(Quantity<Q> q, Quantity<Q> divisor) { q.subtract(divisor) }
}
请注意,我们有许多方法的较长和较短版本,例如 `kg` 和 kilogram
、`m` 和 meter
。我们不需要 multiply
方法,因为它已经在使用 Groovy 预期的名称。
现在,我们可以编写非常简短的定义来声明或比较时间和长度
def s = 1.s
assert 1000.meters == 1.km && 1.m == 100.cm
我们还可以声明地球和火星上的重力加速度变量。火星上的重力要小得多
var gₘ = 3.7.m/s/s
var gₑ = 9.8.m/s/s
assert gₑ.toString() == '9.8 m/s²'
assert gₑ > gₘ
我们还可以在计算中使用运算符重载(这里显示地球的直径是火星的 1.8 到 2 倍)
var diameterₑ = 12_742.kilometers
assert diameterₘ + diameterₘ > diameterₑ
assert diameterₑ - diameterₘ < diameterₘ
assert diameterₘ * 1.8 < diameterₑ
即使我们有更紧凑的表达式,但之前看到的相同数据类型仍在起作用。它们只是更容易输入。
用于控制火星漫游车的动态 DSL
现在让我们看看如何编写一个简单的领域特定语言 (DSL) 来控制火星漫游车机器人。
首先,我们将编写一个 Direction
枚举作为我们机器人领域模型的一部分
enum Direction {
left, right, forward, backward
}
在 Groovy 中编写 DSL 的方法有很多。我们将使用一个技巧,其中动词表示为映射中的键。然后,我们的 DSL 看起来像这样
def move(Direction dir) {
[by: { Quantity<Length> dist ->
[at: { Quantity<Speed> speed ->
println "robot moved $dir by $dist at $speed"
}]
}]
}
这里的实现只是打印一条消息,指示它正在处理的所有值。真正的机器人将向漫游车的机器人子系统发送信号。
我们用于控制漫游车的脚本现在看起来像这样
move right by 2.m at 5.cm/s
运行时,它将输出以下内容
robot moved right by 2 m at 5 cm/s
如我们之前所见,它由我们的 JSR 385 类型支持。如果涉及不匹配类型的任何计算,我们肯定会收到尽早失败的运行时错误。
如果我们启用静态类型,一些额外的错误将在编译时检测到,但由于我们的 DSL 实现的非常动态的风格,并非所有运行时错误都反映在类型信息中。如果需要,我们可以更改 DSL 实现以使用更丰富的类型,这将支持更好的静态类型检查。接下来,我们将看一下如何做到这一点。
用于漫游车的类型丰富的 DSL
现在,我们不使用之前看到的嵌套映射风格,而是创建几个类型丰富的辅助类,并根据这些类定义我们的 move
方法
class MoveHolder {
Direction dir
ByHolder by(Quantity<Length> dist) {
new ByHolder(dist: dist, dir: dir)
}
}
class ByHolder {
Quantity<Length> dist
Direction dir
void at(Quantity<Speed> speed) {
println "robot moved $dir by $dist at $speed"
}
}
static MoveHolder move(Direction dir) {
new MoveHolder(dir: dir)
}
虽然我们的 DSL 实现已更改,但机器人脚本保持不变
move right by 2.m at 5.cm/s
事实上,如果我们使用 Groovy 的动态特性,我们仍然可以运行相同的脚本,并且不会注意到任何变化。
但是,如果我们启用静态检查,并且有一个带有像这样的错误的脚本
move forward by 2.kgs
我们现在将看到一个编译时错误
[Static type checking] - Cannot call MoveHolder#by(javax.measure.Quantity) with arguments [javax.measure.Quantity]
能够尽早获得有关脚本错误的额外反馈非常棒,所以你可能想知道为什么我们不始终以这种方式编写 DSL 实现?实际上,我们 DSL 的动态和静态风格在不同的时间都很有用。当我们对脚本 DSL 进行原型设计时,确定我们应该使用哪些名词和动词来控制机器人,动态风格的编写速度会快得多,尤其是在早期迭代中,这些迭代可能会快速发展和变化。一旦 DSL 语言确定下来,我们就可以投资添加更丰富的类型。在漫游车场景中,也可能是漫游车本身的功率有限,因此可能不想执行额外的类型检查步骤。我们可以在将脚本发送到漫游车之前,先通过类型检查器运行所有脚本,然后在漫游车中动态模式下执行它们。
添加自定义类型检查
还有一个 Groovy 的语言特性我们没有提到。Groovy 的类型检查机制是可扩展的,因此我们将在这里看一下如何使用此特性。漫游车的速度相当有限,“但是,在探索火星的情况下,速度并不是最重要的品质。重要的是旅程和沿途的目的地。慢速行驶是一种节能的方式……”。让我们看看如何限制速度以避免不安全的移动或浪费能量。
我们可以在 DSL 实现中添加早期的防御性检查,以检测不良操作,但我们也可以使用类型检查扩展来处理某些类型的错误。事实上,Groovy 有自己的 DSL 用于编写此类扩展。这将是另一篇博客的主题,但以下代码的样子
afterMethodCall { call ->
def method = getTargetMethod(call)
if (method.name != 'at') return
if (call.arguments.size() != 1) return
def arg = call.arguments[0]
if (arg !instanceof BinaryExpression) return
def left = arg.leftExpression
if (left !instanceof PropertyExpression) return
def obj = left.objectExpression
if (obj !instanceof ConstantExpression) return
if (obj.value > 5) {
addStaticTypeError("Speed of $obj.value is too fast!",call)
handled = true
}
}
这只是一个部分实现,它做出了很多假设。我们可以通过添加更多代码来消除这些假设,但现在我们将保留这个简化的版本。
因此,现在以下脚本(应用了上述类型检查扩展)可以正常编译
move right by 2.m at 5.cm/s
但此脚本失败了
move right by 2.m at 6.cm/s
错误消息是
[Static type checking] - Speed of 6 is too fast!
更多信息
结论
我们已经研究了使用 JSR 385 javax.measure
API 与 Groovy 的结合,并添加了一些 DSL 示例,使使用该 API 更方便。