火星上的生活:度量单位系统、Groovy 和领域特定语言 (DSL)

作者:Paul King
发布日期:2022-08-13 06:31AM


duke_measuring 火星气候探测器于 1998 年发射,是多方面火星探测计划的一部分。它在接近火星时由于轨迹计算错误而丢失。一项调查将故障归因于两个软件系统之间的测量不匹配:NASA 使用公制单位,而航天器制造商洛克希德·马丁公司使用美国习惯单位火星气候探测器 - 图片来源:维基百科

如果所讨论的系统是使用度量单位系统开发的,也许可以避免故障。

度量单位系统

所有编程语言都有用于表示数字的类型。例如,我们可能有三个整数,一个表示高度,一个表示重量,一个表示温度。我们可以编写代码将这三个整数加在一起,但结果可能没有有用的意义。我们可以开始为每种整数类型编写自己的类层次结构,但使用结果类很快就会变得很麻烦。度量单位系统试图提供一系列常见单位、从这些单位构建数量的方法以及操作它们的方法。操作可能涉及执行数值计算或转换。此类系统的目标包括能够提供运行时和/或编译时类型安全。因此,当我们尝试执行前面提到的三个不相关数量的加法时,我们应该尽早失败。

度量单位系统并不新鲜。F# 和 Swift 中存在现有系统,Java 已经围绕标准化此类系统进行了多个版本(以及更早的尝试),包括

  • JSR 275:单位规范(早期的尝试被拒绝)

  • JSR 363:度量单位 API

  • JSR 385:度量单位 API 2.0

还有一些现有的 Java 库,例如 JScience,它们已被开发,并且 显示出早期的希望,但现在似乎没有得到积极维护。虽然关于度量单位系统是否会进一步传播到主流编程的 争论还在继续,但现在似乎是检查其状态的好时机。JSR 385 维护版本于去年获得批准,参考实现的最新版本于今年早些时候发布。

JSR 385:度量单位 API 2.0

我们需要做的第一件事是引入我们的依赖项(显示为 Gradle - 其他选项)。主要的是参考实现(它传递地引入了 javax.measure API)

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 具有公制前缀限定符,如 MICROMILLICENTIKILOMEGAGIGA 等等。CLDR 单位还定义了一些常用单位,如 KILOMETER。我们可以在这里选择任一单位。

当我们运行此脚本时,它将输出以下内容

639000000000000000000000 kg
6779 km

如果我们尝试比较或添加这两个值,我们将看到错误。Groovy 具有静态和动态特性。使用像这样的动态代码

println massₘ > diameterₘ

我们将看到这样的运行时错误

javax.measure.IncommensurableException: km is not compatible with kg

或者,在 TypeCheckedCompileStatic 针对像这样的语句生效的情况下

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) 来控制火星漫游车机器人。

Mars rover selfie

首先,我们将编写一个 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 更方便。