火星生活:度量衡系统、Groovy™ 和领域特定语言 (DSLs)
发布时间: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!
更多信息
结论
我们研究了如何使用 Groovy 的 JSR 385 javax.measure
API,并添加了一些 DSL 示例,以使 API 的使用更加友好。