1. 概述

领域特定语言(Domain-Specific Language, DSL)是一种专门用于解决特定领域问题的编程语言

内部 DSL(Internal DSL)是构建在宿主语言之上的,不需要我们自己开发解析器。但它的能力受限于宿主语言本身的灵活性。对于像 Java 或 C# 这样语法较为严格、扩展性有限的语言来说,最多只能做到提供流畅接口(Fluent Interface)风格的类库。

Scala 凭借其灵活的语法特性,是编写内部 DSL 的理想选择

在本教程中,我们将构建一个简单的 DSL,使得我们可以直接写:

20 seconds

而不是:

new FiniteDuration(20, SECONDS)

2. 一点提醒

Scala 并发包中的 scala.concurrent.duration 实现在 2.10 版本之前是基于 隐式转换 实现的。而我们现在将使用 隐式类(implicit class) 来替代。

这两种方式功能上等价,但隐式类更简洁且安全 ✅。

3. 它是怎么工作的?

为了实现这种“语法糖”,我们需要做两件事:

  • 创建一个包装类
  • 将该类标记为隐式类

之后,Scala 编译器会自动帮我们完成很多语法层面的优化。

3.1. 包装类的设计

我们先来创建一个包装类 class

package com.baeldung.scala

import scala.concurrent.duration.{FiniteDuration, MILLISECONDS, MINUTES, SECONDS}

class DurationSugar(time: Long) {
  def milliseconds: FiniteDuration = new FiniteDuration(time, MILLISECONDS)

  def seconds: FiniteDuration = new FiniteDuration(time, SECONDS)

  def minutes: FiniteDuration = new FiniteDuration(time, MINUTES)
}

现在你可以这样调用:

(new DurationSugar(20)).seconds

看起来并没有简化多少 😅。但我们已经有了一个可以扩展的方法模块,后续可以轻松添加如 hours 等单位的支持。

⚠️ 注意:这里必须显式导入 scala.concurrent.duration 中的具体成员,而不是通配符导入。

3.2. 使用隐式类提升体验

由于 DurationSugar 只接受一个参数,我们可以将其标记为隐式类。但要注意:

隐式类不能作为顶级定义(top-level definition),否则编译器会报错。

我们的目标是只需一行导入,就能在整个作用域内使用这个语法糖:

import com.baeldung.scala.durationsugar._

object Main {
  println(20 seconds)
}

要实现这一点,只需把类放到 package object 中并声明为 implicit class

package object durationsugar {

  implicit class DurationSugar(time: Long) {
    def milliseconds: FiniteDuration = new FiniteDuration(time, MILLISECONDS)

    def seconds: FiniteDuration = new FiniteDuration(time, SECONDS)

    def minutes: FiniteDuration = new FiniteDuration(time, MINUTES)
  }
}

✅ 命名建议:隐式类的名字尽量长一些,避免与用户代码中的变量或方法冲突。

⚠️ 隐式类的构造函数不能超过一个参数。虽然 Scala 允许定义多个参数的隐式类,但它不会参与隐式查找过程,就失去了意义。

3.3. 其余部分由 Scala 自动处理

剩下的语法支持都是免费的 🎁。

比如:

  • 可以省略无参方法后面的括号:

    20.seconds
    
  • 在大多数情况下,也可以省略点号 ., 写成:

    20 seconds
    

举个例子:

"20.seconds" should "equal the object created with the native scala sugar" in {
  20.seconds shouldBe new FiniteDuration(20, SECONDS)
}

⚠️ 但要注意:点号省略语法在某些复杂上下文中可能会让编译器混淆,特别是在后面还有链式调用时。此时最好保留点号,否则可能遇到令人困惑的错误提示:

Compiler Error

4. 让它更甜一点

隐式类不仅能用来构造对象,还可以用来给已有的类扩展新方法。

比如我们可以给 FiniteDuration 添加一个新的操作符方法 ++

implicit class DurationOps(duration: FiniteDuration) {
  def ++(other: FiniteDuration): FiniteDuration =
    (duration.unit, other.unit) match {
      case (a, b) if a == b =>
        new FiniteDuration(duration.length + other.length, duration.unit)
      case (MILLISECONDS, _) =>
        new FiniteDuration(duration.length + other.toMillis, MILLISECONDS)
      case (_, MILLISECONDS) =>
        new FiniteDuration(duration.toMillis + other.length, MILLISECONDS)
      case (SECONDS, _) =>
        new FiniteDuration(duration.length + other.toSeconds, SECONDS)
      case (_, SECONDS) =>
        new FiniteDuration(duration.toSeconds + other.length, SECONDS)
    }
}

因为原生类已经有 + 方法了,所以我们使用 ++ 来避免冲突。

通过 模式匹配,我们可以判断单位是否一致,并统一转换为较小单位后再相加。

最终效果如下:

"20.seconds ++ 30.seconds" should "be equal to 50.seconds" in {
  20.seconds ++ 30.seconds shouldBe 50.seconds
}

"20.seconds ++ 1.minutes" should "be equal to 80.seconds" in {
  20.seconds ++ 1.minutes shouldBe 80.seconds
}

记住:Scala 中没有方法和操作符的区别,只是允许你在方法名中使用特殊字符,并可以用类似操作符的方式调用它们

5. 总结

Scala 的隐式类是一种非常高效的手段,可以在不修改原有类型的前提下为其扩展功能

我们只用了三个特性:

  • 隐式类
  • 方法命名灵活性
  • 语法糖机制

但这并不是 Scala DSL 能力的终点。其他可用于 DSL 构建的强大特性还包括:

  • 伴生对象(Companion Objects)
  • apply 方法
  • 高阶函数和 Lambda 表达式
  • 使用 {} 替代 () 调用单参数方法的能力

一如既往,本文完整源码可以在 GitHub 上找到。


原始标题:A DSL for Writing “20 seconds” in Scala