语义

本章介绍 Groovy 编程语言的语义。

1. 语句

1.1. 变量定义

变量可以使用其类型(如 String)或使用关键字 def(或 var)后跟变量名来定义

String x
def y
var z

当您不想给出显式类型时,defvar 充当类型占位符,即类型名称的替代。这可能是因为您在编译时不需要关心类型,或者依赖于类型推断(结合 Groovy 的静态特性)。变量定义必须具有类型或占位符。如果省略,类型名称将被视为引用现有变量(可能在前面已声明)。对于脚本,未声明的变量被认为来自脚本绑定。在其他情况下,您将收到缺少属性(动态 Groovy)或编译时错误(静态 Groovy)。如果您将 defvar 视为 Object 的别名,您将立即理解。

变量定义可以提供初始值,在这种情况下,它就像声明和赋值(我们接下来将介绍)合二为一。

变量定义类型可以通过使用泛型来细化,例如 List<String> names。要了解有关泛型支持的更多信息,请阅读泛型部分

1.2. 变量赋值

您可以将值赋给变量以供以后使用。尝试以下操作

x = 1
println x

x = new java.util.Date()
println x

x = -3.1499392
println x

x = false
println x

x = "Hi"
println x

1.2.1. 多重赋值

Groovy 支持多重赋值,即可以同时为多个变量赋值,例如

def (a, b, c) = [10, 20, 'foo']
assert a == 10 && b == 20 && c == 'foo'

您可以根据需要将类型作为声明的一部分提供

def (int i, String j) = [10, 'foo']
assert i == 10 && j == 'foo'

以及用于声明变量,它也适用于现有变量

def nums = [1, 3, 5]
def a, b, c
(a, b, c) = nums
assert a == 1 && b == 3 && c == 5

此语法适用于数组和列表,以及返回其中任何一种的方法

def (_, month, year) = "18th June 2009".split()
assert "In $month of $year" == 'In June of 2009'

1.2.2. 溢出和下溢

如果左侧变量过多,则多余的变量将填充 null

def (a, b, c) = [1, 2]
assert a == 1 && b == 2 && c == null

如果右侧变量过多,则多余的变量将被忽略

def (a, b) = [1, 2, 3]
assert a == 1 && b == 2

1.2.3. 多重赋值与对象解构

在描述 Groovy 运算符的部分中,已经介绍了下标运算符的情况,解释了如何重写 getAt()/putAt() 方法。

通过这种技术,我们可以结合多重赋值和下标运算符方法来实现 对象解构

考虑以下不可变的 Coordinates 类,其中包含一对经度和纬度双精度值,并注意我们对 getAt() 方法的实现

@Immutable
class Coordinates {
    double latitude
    double longitude

    double getAt(int idx) {
        if (idx == 0) latitude
        else if (idx == 1) longitude
        else throw new Exception("Wrong coordinate index, use 0 or 1")
    }
}

现在让我们实例化这个类并解构它的经度和纬度

def coordinates = new Coordinates(latitude: 43.23, longitude: 3.67) (1)

def (la, lo) = coordinates                                          (2)

assert la == 43.23                                                  (3)
assert lo == 3.67
1 我们创建 Coordinates 类的一个实例
2 然后,我们使用多重赋值来获取单独的经度和纬度值
3 最后我们可以断言它们的值。

1.3. 控制结构

1.3.1. 条件结构

if / else

Groovy 支持 Java 中常见的 if - else 语法

def x = false
def y = false

if ( !x ) {
    x = true
}

assert x == true

if ( x ) {
    x = false
} else {
    y = true
}

assert x == y

Groovy 还支持正常的 Java "嵌套" if then else if 语法

if ( ... ) {
    ...
} else if (...) {
    ...
} else {
    ...
}
switch / case

Groovy 中的 switch 语句与 Java 代码向后兼容;因此您可以穿透案例以实现多重匹配的共享代码。

一个不同之处是 Groovy switch 语句可以处理任何类型的 switch 值,并且可以执行不同类型的匹配。

def x = 1.23
def result = ""

switch (x) {
    case "foo":
        result = "found foo"
        // lets fall through

    case "bar":
        result += "bar"

    case [4, 5, 6, 'inList']:
        result = "list"
        break

    case 12..30:
        result = "range"
        break

    case Integer:
        result = "integer"
        break

    case Number:
        result = "number"
        break

    case ~/fo*/: // toString() representation of x matches the pattern?
        result = "foo regex"
        break

    case { it < 0 }: // or { x < 0 }
        result = "negative"
        break

    default:
        result = "default"
}

assert result == "number"

Switch 支持以下类型的比较

  • 如果 switch 值是该类的一个实例,则类 case 值匹配

  • 如果 switch 值的 toString() 表示匹配正则表达式,则正则表达式 case 值匹配

  • 如果 switch 值包含在集合中,则集合 case 值匹配。这还包括范围(因为它们是列表)

  • 如果调用闭包返回的结果根据Groovy 真值为 true,则闭包 case 值匹配

  • 如果以上都没有使用,则如果 case 值等于 switch 值,则 case 值匹配

当使用闭包 case 值时,默认的 it 参数实际上是 switch 值(在我们的示例中是变量 x)。

Groovy 还支持 switch 表达式,如以下示例所示

def partner = switch(person) {
    case 'Romeo'  -> 'Juliet'
    case 'Adam'   -> 'Eve'
    case 'Antony' -> 'Cleopatra'
    case 'Bonnie' -> 'Clyde'
}

1.3.2. 循环结构

经典 for 循环

Groovy 支持标准的 Java / C for 循环

String message = ''
for (int i = 0; i < 5; i++) {
    message += 'Hi '
}
assert message == 'Hi Hi Hi Hi Hi '
增强型经典 Java 风格 for 循环

现在支持 Java 经典 for 循环的更复杂形式,带有逗号分隔的表达式。示例

def facts = []
def count = 5
for (int fact = 1, i = 1; i <= count; i++, fact *= i) {
    facts << fact
}
assert facts == [1, 2, 6, 24, 120]
多重赋值与 for 循环结合

Groovy 自 Groovy 1.6 以来一直支持多重赋值语句

// multi-assignment with types
def (String x, int y) = ['foo', 42]
assert "$x $y" == 'foo 42'

现在它们可以出现在 for 循环中

// multi-assignment goes loopy
def baNums = []
for (def (String u, int v) = ['bar', 42]; v < 45; u++, v++) {
    baNums << "$u $v"
}
assert baNums == ['bar 42', 'bas 43', 'bat 44']
for in 循环

Groovy 中的 for 循环更简单,适用于任何类型的数组、集合、Map 等。

// iterate over a range
def x = 0
for ( i in 0..9 ) {
    x += i
}
assert x == 45

// iterate over a list
x = 0
for ( i in [0, 1, 2, 3, 4] ) {
    x += i
}
assert x == 10

// iterate over an array
def array = (0..4).toArray()
x = 0
for ( i in array ) {
    x += i
}
assert x == 10

// iterate over a map
def map = ['abc':1, 'def':2, 'xyz':3]
x = 0
for ( e in map ) {
    x += e.value
}
assert x == 6

// iterate over values in a map
x = 0
for ( v in map.values() ) {
    x += v
}
assert x == 6

// iterate over the characters in a string
def text = "abc"
def list = []
for (c in text) {
    list.add(c)
}
assert list == ["a", "b", "c"]
Groovy 还支持带冒号的 Java 冒号变体:for (char c : text) {}
while 循环

Groovy 支持像 Java 一样常用的 while {…​} 循环

def x = 0
def y = 5

while ( y-- > 0 ) {
    x++
}

assert x == 5
do/while 循环

现在支持 Java 的 do/while 循环。示例

// classic Java-style do..while loop
def count = 5
def fact = 1
do {
    fact *= count--
} while(count > 1)
assert fact == 120

1.3.3. 异常处理

异常处理与 Java 相同。

1.3.4. try / catch / finally

您可以指定一个完整的 try-catch-finallytry-catchtry-finally 块集。

每个块的主体都需要大括号。
try {
    'moo'.toLong()   // this will generate an exception
    assert false     // asserting that this point should never be reached
} catch ( e ) {
    assert e in NumberFormatException
}

我们可以在匹配的 'try' 子句后将代码放在 'finally' 子句中,这样无论 'try' 子句中的代码是否抛出异常,finally 子句中的代码都将始终执行

def z
try {
    def i = 7, j = 0
    try {
        def k = i / j
        assert false        //never reached due to Exception in previous line
    } finally {
        z = 'reached here'  //always executed even if Exception thrown
    }
} catch ( e ) {
    assert e in ArithmeticException
    assert z == 'reached here'
}

1.3.5. 多重捕获

通过多重捕获块(自 Groovy 2.0 起),我们能够定义多个异常,并由同一个捕获块处理

try {
    /* ... */
} catch ( IOException | NullPointerException e ) {
    /* one block to handle 2 exceptions */
}

1.3.6. ARM Try with resources

Groovy 通常为 Java 7 的 try-with-resources 语句(用于自动资源管理 (ARM))提供了更好的替代方案。现在支持 Java 程序员迁移到 Groovy 并且仍然希望使用旧风格的语法

class FromResource extends ByteArrayInputStream {
    @Override
    void close() throws IOException {
        super.close()
        println "FromResource closing"
    }

    FromResource(String input) {
        super(input.toLowerCase().bytes)
    }
}

class ToResource extends ByteArrayOutputStream {
    @Override
    void close() throws IOException {
        super.close()
        println "ToResource closing"
    }
}

def wrestle(s) {
    try (
            FromResource from = new FromResource(s)
            ToResource to = new ToResource()
    ) {
        to << from
        return to.toString()
    }
}

def wrestle2(s) {
    FromResource from = new FromResource(s)
    try (from; ToResource to = new ToResource()) { // Enhanced try-with-resources in Java 9+
        to << from
        return to.toString()
    }
}

assert wrestle("ARM was here!").contains('arm')
assert wrestle2("ARM was here!").contains('arm')

产生以下输出

ToResource closing
FromResource closing
ToResource closing
FromResource closing

1.4. 强大的断言

与 Java 不同,Groovy 与 Java 共享 assert 关键字,但后者在 Groovy 中的行为非常不同。首先,Groovy 中的断言总是被执行,与 JVM 的 -ea 标志无关。这使得它成为单元测试的首选。 “强大断言”的概念与 Groovy assert 的行为直接相关。

一个强大的断言分解为 3 个部分

assert [left expression] == [right expression] : (optional message)

断言的结果与您在 Java 中获得的结果大不相同。如果断言为真,则什么也不会发生。如果断言为假,则它提供被断言表达式的每个子表达式的值的可视化表示。例如

assert 1+1 == 3

将产生

Caught: Assertion failed:

assert 1+1 == 3
        |  |
        2  false

当表达式更复杂时,强大的断言变得非常有趣,如下一个示例所示

def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == [x,z].sum()

这将打印每个子表达式的值

assert calc(x,y) == [x,z].sum()
       |    | |  |   | |  |
       15   2 7  |   2 5  7
                 false

如果您不想要像上面那样漂亮的错误消息,可以通过更改断言的可选消息部分来回退到自定义错误消息,如下例所示

def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == z*z : 'Incorrect computation result'

这将打印以下错误消息

Incorrect computation result. Expression: (calc.call(x, y) == (z * z)). Values: z = 5, z = 5

1.5. 标签语句

任何语句都可以与标签相关联。标签不会影响代码的语义,并且可以用于使代码更易于阅读,如下例所示

given:
    def x = 1
    def y = 2
when:
    def z = x+y
then:
    assert z == 3

尽管没有改变带标签语句的语义,但可以在 break 指令中使用标签作为跳转目标,如下一个示例所示。然而,即使允许这样做,这种编码风格通常被认为是不良实践

for (int i=0;i<10;i++) {
    for (int j=0;j<i;j++) {
        println "j=$j"
        if (j == 5) {
            break exit
        }
    }
    exit: println "i=$i"
}

重要的是要理解,默认情况下标签对代码的语义没有影响,但是它们属于抽象语法树 (AST),因此 AST 转换可以使用该信息对代码执行转换,从而导致不同的语义。Spock Framework Spock Framework 就是这样做的,以使测试更容易。

2. 表达式

表达式是 Groovy 程序的基本构建块,用于引用现有值并执行代码以创建新值。

Groovy 支持许多与 Java 相同的表达式类型,包括

表 1. 类似 Java 的表达式

示例表达式

描述

foo

变量、字段、参数的名称等

thissuperit

特殊名称

true10"bar"

字面量

String.class

类字面量

( 表达式 )

带括号的表达式

foo++, ~bar

一元运算符表达式

foo + bar, bar * baz

二元运算符表达式

foo ? bar : baz

三元运算符表达式

(Integer x, Integer y) → x + y

Lambda 表达式

assert 'bar' == switch('foo') {
  case 'foo' -> 'bar'
}

switch 表达式

Groovy 也有一些自己的特殊表达式

表 2. 特殊表达式

示例表达式

描述

String

缩写类字面量(无歧义时)

{ x, y → x + y }

闭包表达式

[1, 3, 5]

列表字面量表达式

[a:2, b:4, c:6]

map 字面量表达式

Groovy 还扩展了 Java 中用于成员访问的普通点符号。Groovy 通过指定感兴趣数据的层次结构中的路径来提供访问分层数据的特殊支持。这些 Groovy 路径 表达式被称为 GPath 表达式。

2.1. GPath 表达式

GPath 是一种集成到 Groovy 中的路径表达式语言,它允许识别嵌套结构化数据的部分。从这个意义上说,它的目标和范围与 XPath 对 XML 的目标和范围相似。GPath 通常用于处理 XML 的上下文中,但它确实适用于任何对象图。XPath 使用类似文件系统的路径表示法,一个用斜杠 / 分隔部分的树形层次结构,而 GPath 使用点对象表示法 来执行对象导航。

例如,您可以指定对象或感兴趣元素的路径

  • a.b.c → 对于 XML,产生 ab 中的所有 c 元素

  • a.b.c → 对于 POJO,产生 a 的所有 b 属性的 c 属性(有点像 JavaBeans 中的 a.getB().getC()

在这两种情况下,GPath 表达式都可以被视为对对象图的查询。对于 POJO,对象图通常由正在编写的程序通过对象实例化和组合来构建;对于 XML 处理,对象图是解析 XML 文本的结果,最常使用 XmlParser 或 XmlSlurper 等类。有关 Groovy 中使用 XML 的更多深入细节,请参阅处理 XML

当查询从 XmlParser 或 XmlSlurper 生成的对象图时,GPath 表达式可以使用 @ 符号引用在元素上定义的属性

  • a["@href"] → 类似 Map 的表示法:所有 a 元素的 href 属性

  • a.'@href' → 属性表示法:另一种表达方式

  • a.@href → 直接表示法:又一种表达方式

2.1.1. 对象导航

让我们看一个 GPath 表达式在简单 对象图 上的示例,即使用 Java 反射获得的对象图。假设您在一个类的一个非静态方法中,该类有另一个名为 aMethodFoo 的方法

void aMethodFoo() { println "This is aMethodFoo." } (0)

以下 GPath 表达式将获取该方法的名称

assert ['aMethodFoo'] == this.class.methods.name.grep(~/.*Foo/)

更精确地说,上述 GPath 表达式生成一个字符串列表,每个字符串都是 this 上现有方法的名称,其中该名称以 Foo 结尾。

现在,给定该类中还定义的以下方法

void aMethodBar() { println "This is aMethodBar." } (1)
void anotherFooMethod() { println "This is anotherFooMethod." } (2)
void aSecondMethodBar() { println "This is aSecondMethodBar." } (3)

那么以下 GPath 表达式将获取 (1)(3) 的名称,但不包括 (2)(0)

assert ['aMethodBar', 'aSecondMethodBar'] as Set == this.class.methods.name.grep(~/.*Bar/) as Set

2.1.2. 表达式解构

我们可以分解表达式 this.class.methods.name.grep(~/.*Bar/) 来了解 GPath 是如何求值的

this.class

属性访问器,相当于 Java 中的 this.getClass(),产生一个 Class 对象。

this.class.methods

属性访问器,相当于 this.getClass().getMethods(),产生一个 Method 对象数组。

this.class.methods.name

对数组的每个元素应用属性访问器并生成结果列表。

this.class.methods.name.grep(…​)

this.class.methods.name 产生的列表的每个元素上调用 grep 方法,并生成结果列表。

this.class.methods 这样的子表达式会产生一个数组,因为这是在 Java 中调用 this.getClass().getMethods() 会产生的结果。 GPath 表达式没有约定 s 表示列表或类似的东西。

GPath 表达式的一个强大特性是,对集合的属性访问会转换为 对集合中每个元素的属性访问,并将结果收集到一个集合中。因此,表达式 this.class.methods.name 在 Java 中可以表示为如下

List<String> methodNames = new ArrayList<String>();
for (Method method : this.getClass().getMethods()) {
   methodNames.add(method.getName());
}
return methodNames;

当存在集合时,数组访问表示法也可以在 GPath 表达式中使用

assert 'aSecondMethodBar' == this.class.methods.name.grep(~/.*Bar/).sort()[1]
GPath 表达式中的数组访问是零索引的

2.1.3. 用于 XML 导航的 GPath

这是一个 XML 文档和各种形式的 GPath 表达式的示例

def xmlText = """
              | <root>
              |   <level>
              |      <sublevel id='1'>
              |        <keyVal>
              |          <key>mykey</key>
              |          <value>value 123</value>
              |        </keyVal>
              |      </sublevel>
              |      <sublevel id='2'>
              |        <keyVal>
              |          <key>anotherKey</key>
              |          <value>42</value>
              |        </keyVal>
              |        <keyVal>
              |          <key>mykey</key>
              |          <value>fizzbuzz</value>
              |        </keyVal>
              |      </sublevel>
              |   </level>
              | </root>
              """
def root = new XmlSlurper().parseText(xmlText.stripMargin())
assert root.level.size() == 1 (1)
assert root.level.sublevel.size() == 2 (2)
assert root.level.sublevel.findAll { it.@id == 1 }.size() == 1 (3)
assert root.level.sublevel[1].keyVal[0].key.text() == 'anotherKey' (4)
1 root 下有一个 level 节点
2 root/level 下有两个 sublevel 节点
3 有一个元素 sublevel 具有属性 id,其值为 1
4 root/level 下的第二个 sublevel 元素的第一个 keyVal 元素的 key 元素的文本值为 'anotherKey'

有关 XML 的 GPath 表达式的更多详细信息,请参阅XML 用户指南

3. 提升与强制转换

3.1. 数字提升

数字提升的规则在数学运算部分中指定。

3.2. 闭包到类型强制转换

3.2.1. 将闭包分配给 SAM 类型

SAM 类型是定义单个抽象方法的类型。这包括

函数式接口
interface Predicate<T> {
    boolean accept(T obj)
}
具有单个抽象方法的抽象类
abstract class Greeter {
    abstract String getName()
    void greet() {
        println "Hello, $name"
    }
}

任何闭包都可以使用 as 运算符转换为 SAM 类型

Predicate filter = { it.contains 'G' } as Predicate
assert filter.accept('Groovy') == true

Greeter greeter = { 'Groovy' } as Greeter
greeter.greet()

然而,自 Groovy 2.2.0 起,as Type 表达式是可选的。您可以省略它并简单地写入

Predicate filter = { it.contains 'G' }
assert filter.accept('Groovy') == true

Greeter greeter = { 'Groovy' }
greeter.greet()

这意味着您还可以使用方法指针,如下例所示

boolean doFilter(String s) { s.contains('G') }

Predicate filter = this.&doFilter
assert filter.accept('Groovy') == true

Greeter greeter = GroovySystem.&getVersion
greeter.greet()

3.2.2. 使用闭包调用接受 SAM 类型的方法

闭包到 SAM 类型强制转换的第二个也是可能更重要的用例是调用接受 SAM 类型的方法。想象以下方法

public <T> List<T> filter(List<T> source, Predicate<T> predicate) {
    source.findAll { predicate.accept(it) }
}

然后你可以用闭包调用它,而无需创建接口的显式实现

assert filter(['Java','Groovy'], { it.contains 'G'} as Predicate) == ['Groovy']

但自 Groovy 2.2.0 起,您还可以省略显式强制转换,并像使用闭包一样调用该方法

assert filter(['Java','Groovy']) { it.contains 'G'} == ['Groovy']

如您所见,这具有让您使用闭包语法进行方法调用的优点,也就是说将闭包放在括号外,从而提高代码的可读性。

3.2.3. 闭包到任意类型强制转换

除了 SAM 类型之外,闭包还可以强制转换为任何类型,特别是接口。让我们定义以下接口

interface FooBar {
    int foo()
    void bar()
}

您可以使用 as 关键字将闭包强制转换为接口

def impl = { println 'ok'; 123 } as FooBar

这将生成一个类,其中所有方法都使用闭包实现

assert impl.foo() == 123
impl.bar()

但也可以将闭包强制转换为任何类。例如,我们可以将定义的 interface 替换为 class,而不改变断言

class FooBar {
    int foo() { 1 }
    void bar() { println 'bar' }
}

def impl = { println 'ok'; 123 } as FooBar

assert impl.foo() == 123
impl.bar()

3.3. Map 到类型强制转换

通常,使用单个闭包来实现具有多个方法的接口或类并不是最佳选择。作为替代方案,Groovy 允许您将 Map 强制转换为接口或类。在这种情况下,Map 的键被解释为方法名,而值是方法的实现。以下示例说明了将 Map 强制转换为 Iterator 的过程

def map
map = [
  i: 10,
  hasNext: { map.i > 0 },
  next: { map.i-- },
]
def iter = map as Iterator

当然,这是一个相当牵强的例子,但它说明了这个概念。您只需要实现实际调用的那些方法,但如果调用了 Map 中不存在的方法,则会抛出 MissingMethodExceptionUnsupportedOperationException,具体取决于传递给调用的参数,如下例所示

interface X {
    void f()
    void g(int n)
    void h(String s, int n)
}

x = [ f: {println "f called"} ] as X
x.f() // method exists
x.g() // MissingMethodException here
x.g(5) // UnsupportedOperationException here

异常的类型取决于调用本身

  • 如果调用的参数与接口/类中的参数不匹配,则为 MissingMethodException

  • 如果调用的参数与接口/类中的某个重载方法匹配,则为 UnsupportedOperationException

3.4. 字符串到枚举强制转换

Groovy 允许透明的 String(或 GString)到枚举值的强制转换。想象您定义了以下枚举

enum State {
    up,
    down
}

那么您可以将字符串分配给枚举,而无需使用显式 as 强制转换

State st = 'up'
assert st == State.up

也可以使用 GString 作为值

def val = "up"
State st = "${val}"
assert st == State.up

然而,这会抛出一个运行时错误 (IllegalArgumentException)

State st = 'not an enum value'

请注意,在 switch 语句中也可以使用隐式强制转换

State switchState(State st) {
    switch (st) {
        case 'up':
            return State.down // explicit constant
        case 'down':
            return 'up' // implicit coercion for return types
    }
}

特别注意 case 如何使用字符串常量。但是,如果您调用一个使用枚举并带有 String 参数的方法,您仍然需要使用显式 as 强制转换

assert switchState('up' as State) == State.down
assert switchState(State.down) == State.up

3.5. 自定义类型强制转换

类可以通过实现 asType 方法来定义自定义强制转换策略。自定义强制转换通过 as 运算符调用,并且从不隐式。例如,假设您定义了两个类,PolarCartesian,如下例所示

class Polar {
    double r
    double phi
}
class Cartesian {
   double x
   double y
}

您想从极坐标转换为笛卡尔坐标。一种方法是在 Polar 类中定义 asType 方法

def asType(Class target) {
    if (Cartesian==target) {
        return new Cartesian(x: r*cos(phi), y: r*sin(phi))
    }
}

它允许您使用 as 强制转换运算符

def sigma = 1E-16
def polar = new Polar(r:1.0,phi:PI/2)
def cartesian = polar as Cartesian
assert abs(cartesian.x-sigma) < sigma

综合起来,Polar 类如下所示

class Polar {
    double r
    double phi
    def asType(Class target) {
        if (Cartesian==target) {
            return new Cartesian(x: r*cos(phi), y: r*sin(phi))
        }
    }
}

但也可以在 Polar 类之外定义 asType,这在您希望为“封闭”类或您不拥有源代码的类定义自定义强制转换策略时非常实用,例如使用元类

Polar.metaClass.asType = { Class target ->
    if (Cartesian==target) {
        return new Cartesian(x: r*cos(phi), y: r*sin(phi))
    }
}

3.6. 类字面量与变量及 as 运算符

只有当您有一个类的静态引用时,才可以使用 as 关键字,如下面的代码所示

interface Greeter {
    void greet()
}
def greeter = { println 'Hello, Groovy!' } as Greeter // Greeter is known statically
greeter.greet()

但是,如果您通过反射获取类,例如通过调用 Class.forName 呢?

Class clazz = Class.forName('Greeter')

尝试将类引用与 as 关键字一起使用将失败

greeter = { println 'Hello, Groovy!' } as clazz
// throws:
// unable to resolve class clazz
// @ line 9, column 40.
//   greeter = { println 'Hello, Groovy!' } as clazz

它之所以失败是因为 as 关键字只适用于类字面量。相反,您需要调用 asType 方法

greeter = { println 'Hello, Groovy!' }.asType(clazz)
greeter.greet()

4. 可选性

4.1. 可选括号

如果至少有一个参数且没有歧义,方法调用可以省略括号

println 'Hello World'
def maximum = Math.max 5, 10

对于无参数方法调用或歧义方法调用,括号是必需的

println()
println(Math.max(5, 10))

4.2. 可选分号

在 Groovy 中,如果行只包含一个语句,则可以省略行尾的分号。

这意味着

assert true;

可以更惯用地写成

assert true

一行中的多个语句需要分号来分隔它们

boolean a = true; assert a

4.3. 可选 return 关键字

在 Groovy 中,方法或闭包主体中评估的最后一个表达式将被返回。这意味着 return 关键字是可选的。

int add(int a, int b) {
    return a+b
}
assert add(1, 2) == 3

可以缩短为

int add(int a, int b) {
    a+b
}
assert add(1, 2) == 3

4.4. 可选 public 关键字

默认情况下,Groovy 类和方法是 public。因此,这个类

public class Server {
    public String toString() { "a server" }
}

与这个类相同

class Server {
    String toString() { "a server" }
}

5. Groovy 真值

Groovy 根据以下规则判断表达式是真还是假。

5.1. 布尔表达式

如果对应的布尔值为 true,则为真。

assert true
assert !false

5.2. 集合和数组

非空集合和数组为真。

assert [1, 2, 3]
assert ![]

5.3. 匹配器

如果匹配器至少有一个匹配,则为真。

assert ('a' =~ /a/)
assert !('a' =~ /b/)

5.4. 迭代器和枚举

具有更多元素的迭代器和枚举被强制转换为真。

assert [0].iterator()
assert ![].iterator()
Vector v = [0] as Vector
Enumeration enumeration = v.elements()
assert enumeration
enumeration.nextElement()
assert !enumeration

5.5. Maps

非空 Map 被评估为真。

assert ['one' : 1]
assert ![:]

5.6. 字符串

非空字符串、GString 和 CharSequences 被强制转换为真。

assert 'a'
assert !''
def nonEmpty = 'a'
assert "$nonEmpty"
def empty = ''
assert !"$empty"

5.7. 数字

非零数字为真。

assert 1
assert 3.5
assert !0

5.8. 对象引用

非空对象引用被强制转换为真。

assert new Object()
assert !null

5.9. 使用 asBoolean() 方法自定义真值

为了自定义 groovy 将您的对象评估为 truefalse,请实现 asBoolean() 方法

class Color {
    String name

    boolean asBoolean(){
        name == 'green' ? true : false 
    }
}

Groovy 将调用此方法将您的对象强制转换为布尔值,例如

assert new Color(name: 'green')
assert !new Color(name: 'red')

6. 类型

6.1. 可选类型

可选类型是指即使您没有在变量上放置显式类型,程序也能工作的想法。作为一种动态语言,Groovy 自然地实现了该功能,例如当您声明一个变量时

String aString = 'foo'                      (1)
assert aString.toUpperCase()                (2)
1 foo 使用显式类型 String 声明
2 我们可以在 String 上调用 toUpperCase 方法

Groovy 允许您这样编写

def aString = 'foo'                         (1)
assert aString.toUpperCase()                (2)
1 foo 使用 def 声明
2 我们仍然可以调用 toUpperCase 方法,因为 aString 的类型在运行时解析

所以这里你是否使用显式类型无关紧要。当你将此功能与静态类型检查结合使用时,它特别有趣,因为类型检查器执行类型推断。

同样,Groovy 不强制要求在方法中声明参数类型

String concat(String a, String b) {
    a+b
}
assert concat('foo','bar') == 'foobar'

可以使用 def 重写,作为返回类型和参数类型,以利用鸭子类型,如下例所示

def concat(def a, def b) {                              (1)
    a+b
}
assert concat('foo','bar') == 'foobar'                  (2)
assert concat(1,2) == 3                                 (3)
1 返回类型和参数类型都使用 def
2 它使得使用 String 调用该方法成为可能
3 但也适用于 int,因为定义了 plus 方法
此处推荐使用 def 关键字来描述方法意图,即该方法适用于任何类型,但从技术上讲,我们可以使用 Object 代替,结果将是相同的:在 Groovy 中,def 严格等同于使用 Object

最终,类型可以完全从返回类型和描述符中删除。但是,如果您想从返回类型中删除它,那么您需要为方法添加一个显式修饰符,以便编译器能够区分方法声明和方法调用,如下例所示

private concat(a,b) {                                   (1)
    a+b
}
assert concat('foo','bar') == 'foobar'                  (2)
assert concat(1,2) == 3                                 (3)
1 如果我们要省略返回类型,必须设置一个显式修饰符。
2 仍然可以使用 String 调用该方法
3 也可以使用 int
在公共 API 的方法参数或方法返回类型中省略类型通常被认为是一种不良实践。虽然在局部变量中使用 def 并不是真正的问题,因为变量的可见性仅限于方法本身,但如果设置在方法参数上,def 将在方法签名中转换为 Object,这使得用户难以知道参数的预期类型。这意味着您应该将其限制在明确依赖于鸭子类型的情况下。

6.2. 静态类型检查

默认情况下,Groovy 在编译时执行最小的类型检查。由于它主要是一种动态语言,因此静态编译器通常会执行的大多数检查在编译时是不可能的。通过运行时元编程添加的方法可能会改变类或对象的运行时行为。让我们在以下示例中说明原因

class Person {                                                          (1)
    String firstName
    String lastName
}
def p = new Person(firstName: 'Raymond', lastName: 'Devos')             (2)
assert p.formattedName == 'Raymond Devos'                               (3)
1 Person 类只定义了两个属性:firstNamelastName
2 我们可以创建一个 Person 实例
3 并调用名为 formattedName 的方法

在动态语言中,像上面这样的代码不抛出任何错误是很常见的。这怎么可能?在 Java 中,这通常会在编译时失败。然而,在 Groovy 中,它不会在编译时失败,如果编码正确,也不会在运行时失败。事实上,为了使其在运行时工作,一种 可能性是依赖于运行时元编程。因此,在 Person 类的声明之后添加这一行就足够了

Person.metaClass.getFormattedName = { "$delegate.firstName $delegate.lastName" }

这意味着在 Groovy 中,您通常不能对对象的类型做出任何超出其声明类型的假设,即使您知道,您也无法在编译时确定将调用哪个方法或将检索哪个属性。这有很多好处,从编写 DSL 到测试,本手册的其他部分对此进行了讨论。

然而,如果您的程序不依赖于动态特性,并且您来自静态世界(特别是 Java 思维模式),那么在编译时没有捕获此类“错误”可能会令人惊讶。正如我们在前面的示例中看到的,编译器无法确定这是否是错误。为了让它意识到这是一个错误,您必须明确指示编译器您正在切换到类型检查模式。这可以通过用 @groovy.transform.TypeChecked 注解类或方法来完成。

当类型检查被激活时,编译器会执行更多工作

  • 类型推断被激活,这意味着即使您在局部变量上使用 def,类型检查器也能够从赋值中推断变量的类型

  • 方法调用在编译时解析,这意味着如果类上未声明方法,编译器将抛出错误

  • 一般来说,您习惯在静态语言中找到的所有编译时错误都会出现:找不到方法、找不到属性、方法调用类型不兼容、数字精度错误等

在本节中,我们将描述类型检查器在各种情况下的行为,并解释在代码中使用 @TypeChecked 的限制。

6.2.1. @TypeChecked 注解

在编译时激活类型检查

groovy.transform.TypeChecked 注解启用类型检查。它可以放在类上

@groovy.transform.TypeChecked
class Calculator {
    int sum(int x, int y) { x+y }
}

或者在方法上

class Calculator {
    @groovy.transform.TypeChecked
    int sum(int x, int y) { x+y }
}

在第一种情况下,被注解类的所有方法、属性、字段、内部类等都将进行类型检查,而在第二种情况下,只有该方法及其可能包含的闭包或匿名内部类会进行类型检查。

跳过部分

类型检查的范围可以受到限制。例如,如果一个类被类型检查,您可以指示类型检查器通过使用 @TypeChecked(TypeCheckingMode.SKIP) 注解方法来跳过它

import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode

@TypeChecked                                        (1)
class GreetingService {
    String greeting() {                             (2)
        doGreet()
    }

    @TypeChecked(TypeCheckingMode.SKIP)             (3)
    private String doGreet() {
        def b = new SentenceBuilder()
        b.Hello.my.name.is.John                     (4)
        b
    }
}
def s = new GreetingService()
assert s.greeting() == 'Hello my name is John'
1 GreetingService 类被标记为类型检查
2 所以 greeting 方法会自动进行类型检查
3 但是 doGreet 被标记为 SKIP
4 类型检查器此处不抱怨缺少属性

在前面的示例中,SentenceBuilder 依赖于动态代码。没有真正的 Hello 方法或属性,因此类型检查器通常会抱怨并导致编译失败。由于使用构建器的方法标记为 TypeCheckingMode.SKIP,因此该方法的类型检查被 跳过,因此即使类的其余部分经过类型检查,代码仍将编译。

以下部分描述了 Groovy 中类型检查的语义。

6.2.2. 类型检查赋值

如果且仅当以下条件成立时,类型为 A 的对象 o 可以赋值给类型为 T 的变量

  • T 等于 A

    Date now = new Date()
  • TStringbooleanBooleanClass 之一

    String s = new Date() // implicit call to toString
    Boolean boxed = 'some string'       // Groovy truth
    boolean prim = 'some string'        // Groovy truth
    Class clazz = 'java.lang.String'    // class coercion
  • o 为 null 且 T 不是原始类型

    String s = null         // passes
    int i = null            // fails
  • T 是数组且 A 是数组,并且 A 的组件类型可赋值给 T 的组件类型

    int[] i = new int[4]        // passes
    int[] i = new String[4]     // fails
  • T 是数组且 A 是集合或流,并且 A 的组件类型可赋值给 T 的组件类型

    int[] i = [1,2,3]               // passes
    int[] i = [1,2, new Date()]     // fails
    Set set = [1,2,3]
    Number[] na = set               // passes
    def stream = Arrays.stream(1,2,3)
    int[] i = stream                // passes
  • TA 的超类

    AbstractList list = new ArrayList()     // passes
    LinkedList list = new ArrayList()       // fails
  • TA 实现的接口

    List list = new ArrayList()             // passes
    RandomAccess list = new LinkedList()    // fails
  • TA 是原始类型,并且它们的装箱类型可赋值

    int i = 0
    Integer bi = 1
    int x = Integer.valueOf(123)
    double d = Float.valueOf(5f)
  • T 扩展 groovy.lang.ClosureA 是 SAM 类型(单抽象方法类型)

    Runnable r = { println 'Hello' }
    interface SAMType {
        int doSomething()
    }
    SAMType sam = { 123 }
    assert sam.doSomething() == 123
    abstract class AbstractSAM {
        int calc() { 2* value() }
        abstract int value()
    }
    AbstractSAM c = { 123 }
    assert c.calc() == 246
  • TA 派生自 java.lang.Number 并符合下表

表 3. 数字类型 (java.lang.XXX)
T A 示例

Double

任何类型但 BigDecimal 或 BigInteger

Double d1 = 4d
Double d2 = 4f
Double d3 = 4l
Double d4 = 4i
Double d5 = (short) 4
Double d6 = (byte) 4

Float

任何类型但 BigDecimal、BigInteger 或 Double

Float f1 = 4f
Float f2 = 4l
Float f3 = 4i
Float f4 = (short) 4
Float f5 = (byte) 4

Long

任何类型但 BigDecimal、BigInteger、Double 或 Float

Long l1 = 4l
Long l2 = 4i
Long l3 = (short) 4
Long l4 = (byte) 4

Integer

任何类型但 BigDecimal、BigInteger、Double、Float 或 Long

Integer i1 = 4i
Integer i2 = (short) 4
Integer i3 = (byte) 4

Short

任何类型但 BigDecimal、BigInteger、Double、Float、Long 或 Integer

Short s1 = (short) 4
Short s2 = (byte) 4

Byte

Byte

Byte b1 = (byte) 4

6.2.3. 列表和 Map 构造函数

除了上述赋值规则,如果在类型检查模式下赋值被视为无效,则 列表 字面量或 Map 字面量 A 可以赋值给类型 T 的变量,如果

  • 该赋值是变量声明,且 A 是列表字面量,并且 T 有一个构造函数,其参数与列表字面量中的元素类型匹配

  • 该赋值是变量声明,且 A 是 Map 字面量,并且 T 有一个无参数构造函数,并且 Map 的每个键都有一个属性

例如,与其写

@groovy.transform.TupleConstructor
class Person {
    String firstName
    String lastName
}
Person classic = new Person('Ada','Lovelace')

您可以使用“列表构造函数”

Person list = ['Ada','Lovelace']

或“Map 构造函数”

Person map = [firstName:'Ada', lastName:'Lovelace']

如果您使用 Map 构造函数,会对 Map 的键执行额外检查,以检查是否定义了同名属性。例如,以下代码将在编译时失败

@groovy.transform.TupleConstructor
class Person {
    String firstName
    String lastName
}
Person map = [firstName:'Ada', lastName:'Lovelace', age: 24]     (1)
1 类型检查器将在编译时抛出错误 No such property: age for class: Person

6.2.4. 方法解析

在类型检查模式下,方法在编译时解析。解析通过名称和参数进行。返回类型与方法选择无关。参数类型根据以下规则与参数的类型匹配

类型为 A 的参数 o 可以用于类型为 T 的参数,当且仅当

  • T 等于 A

    int sum(int x, int y) {
        x+y
    }
    assert sum(3,4) == 7
  • TStringAGString

    String format(String str) {
        "Result: $str"
    }
    assert format("${3+4}") == "Result: 7"
  • o 为 null 且 T 不是原始类型

    String format(int value) {
        "Result: $value"
    }
    assert format(7) == "Result: 7"
    format(null)           // fails
  • T 是数组且 A 是数组,并且 A 的组件类型可赋值给 T 的组件类型

    String format(String[] values) {
        "Result: ${values.join(' ')}"
    }
    assert format(['a','b'] as String[]) == "Result: a b"
    format([1,2] as int[])              // fails
  • TA 的超类

    String format(AbstractList list) {
        list.join(',')
    }
    format(new ArrayList())              // passes
    String format(LinkedList list) {
        list.join(',')
    }
    format(new ArrayList())              // fails
  • TA 实现的接口

    String format(List list) {
        list.join(',')
    }
    format(new ArrayList())                  // passes
    String format(RandomAccess list) {
        'foo'
    }
    format(new LinkedList())                 // fails
  • TA 是原始类型,并且它们的装箱类型可赋值

    int sum(int x, Integer y) {
        x+y
    }
    assert sum(3, new Integer(4)) == 7
    assert sum(new Integer(3), 4) == 7
    assert sum(new Integer(3), new Integer(4)) == 7
    assert sum(new Integer(3), 4) == 7
  • T 扩展 groovy.lang.ClosureA 是 SAM 类型(单抽象方法类型)

    interface SAMType {
        int doSomething()
    }
    int twice(SAMType sam) { 2*sam.doSomething() }
    assert twice { 123 } == 246
    abstract class AbstractSAM {
        int calc() { 2* value() }
        abstract int value()
    }
    int eightTimes(AbstractSAM sam) { 4*sam.calc() }
    assert eightTimes { 123 } == 984
  • TA 派生自 java.lang.Number 并符合与数字赋值相同的规则

如果在编译时找不到具有适当名称和参数的方法,则会抛出错误。与“普通”Groovy 的区别如下例所示

class MyService {
    void doSomething() {
        printLine 'Do something'            (1)
    }
}
1 printLine 是一个错误,但由于我们处于动态模式,因此该错误未在编译时捕获

上面的例子显示了一个 Groovy 能够编译的类。但是,如果您尝试创建一个 MyService 实例并调用 doSomething 方法,那么它将在 运行时 失败,因为 printLine 不存在。当然,我们已经展示了 Groovy 如何使其成为完全有效的调用,例如通过捕获 MethodMissingException 或实现自定义元类,但如果您知道自己不属于这种情况,@TypeChecked 会派上用场

@groovy.transform.TypeChecked
class MyService {
    void doSomething() {
        printLine 'Do something'            (1)
    }
}
1 printLine 这次是一个编译时错误

只需添加 @TypeChecked 即可触发编译时方法解析。类型检查器将尝试在 MyService 类上查找接受 StringprintLine 方法,但找不到。它将以以下消息编译失败

Cannot find matching method MyService#printLine(java.lang.String)

理解类型检查器背后的逻辑很重要:它是一个编译时检查,因此根据定义,类型检查器不了解您所做的任何形式的 运行时 元编程。这意味着没有 @TypeChecked 完全有效的代码,如果您激活类型检查,将 无法 再编译。如果您考虑鸭子类型,这一点尤其正确
class Duck {
    void quack() {              (1)
        println 'Quack!'
    }
}
class QuackingBird {
    void quack() {              (2)
        println 'Quack!'
    }
}
@groovy.transform.TypeChecked
void accept(quacker) {
    quacker.quack()             (3)
}
accept(new Duck())              (4)
1 我们定义了一个 Duck 类,它定义了一个 quack 方法
2 我们定义了另一个 QuackingBird 类,它也定义了一个 quack 方法
3 quacker 的类型不严格,因此由于该方法是 @TypeChecked,我们将获得编译时错误
4 即使在非类型检查的 Groovy 中,这也会通过

存在可能的变通方法,例如引入接口,但基本上,通过激活类型检查,您可以获得类型安全,但会失去语言的一些功能。希望 Groovy 引入了一些功能,例如流类型,以缩小类型检查和非类型检查的 Groovy 之间的差距。

6.2.5. 类型推断

原则

当代码用 @TypeChecked 注解时,编译器会执行类型推断。它不仅仅依赖于静态类型,还使用各种技术来推断变量、返回类型、字面量等,以便即使您激活类型检查器,代码也能保持尽可能干净。

最简单的例子是推断变量的类型

def message = 'Welcome to Groovy!'              (1)
println message.toUpperCase()                   (2)
println message.upper() // compile time error   (3)
1 使用 def 关键字声明变量
2 类型检查器允许调用 toUpperCase
3 调用 upper 将在编译时失败

调用 toUpperCase 有效的原因是 message 的类型被 推断String

类型推断中的变量与字段

值得注意的是,虽然编译器对局部变量执行类型推断,但它 对字段执行任何类型的类型推断,总是回退到字段的 声明类型。为了说明这一点,让我们看一个例子

class SomeClass {
    def someUntypedField                                                                (1)
    String someTypedField                                                               (2)

    void someMethod() {
        someUntypedField = '123'                                                        (3)
        someUntypedField = someUntypedField.toUpperCase()  // compile-time error        (4)
    }

    void someSafeMethod() {
        someTypedField = '123'                                                          (5)
        someTypedField = someTypedField.toUpperCase()                                   (6)
    }

    void someMethodUsingLocalVariable() {
        def localVariable = '123'                                                       (7)
        someUntypedField = localVariable.toUpperCase()                                  (8)
    }
}
1 someUntypedField 使用 def 作为声明类型
2 someTypedField 使用 String 作为声明类型
3 我们可以将 任何 值赋给 someUntypedField
4 但调用 toUpperCase 会在编译时失败,因为该字段未正确键入
5 我们可以将 String 赋值给 String 类型的字段
6 这次允许 toUpperCase
7 如果我们将 String 赋值给局部变量
8 那么允许在局部变量上调用 toUpperCase

为什么会有这样的区别呢?原因在于 线程安全。在编译时,我们无法对字段的类型做出 任何 保证。任何线程都可以在任何时间访问任何字段,并且在方法中为某个类型的变量赋值后到下一行使用该字段之间,另一个线程可能已经更改了字段的内容。局部变量则不然:我们知道它们是否“逃逸”,因此我们可以确保变量的类型随时间保持不变(或不)。请注意,即使字段是 final,JVM 也无法保证,因此类型检查器不会因为字段是否为 final 而表现出不同的行为。

这是我们建议使用 类型化 字段的原因之一。虽然对局部变量使用 def 在类型推断的帮助下是完全可以的,但对字段来说并非如此,字段也属于类的公共 API,因此类型很重要。
集合字面量类型推断

Groovy 为各种类型字面量提供了语法。Groovy 中有三种原生集合字面量

  • 列表,使用 [] 字面量

  • Map,使用 [:] 字面量

  • 范围,使用 from..to(包含),from..<to(右不包含),from<..to(左不包含)和 from<..<to(完全不包含)

字面量的推断类型取决于字面量的元素,如下表所示

字面量 推断类型
def list = []

java.util.List

def list = ['foo','bar']

java.util.List<String>

def list = ["${foo}","${bar}"]

java.util.List<GString> 请注意,GString 不是 String

def map = [:]

java.util.LinkedHashMap

def map1 = [someKey: 'someValue']
def map2 = ['someKey': 'someValue']

java.util.LinkedHashMap<String,String>

def map = ["${someKey}": 'someValue']

java.util.LinkedHashMap<GString,String> 请注意,键是 GString

def intRange = (0..10)

groovy.lang.IntRange

def charRange = ('a'..'z')

groovy.lang.Range<String>:使用边界的类型来推断范围的组件类型

如您所见,除了 IntRange 之外,推断类型都使用了泛型类型来描述集合的内容。如果集合包含不同类型的元素,类型检查器仍会执行组件的类型推断,但会使用最小上界的概念。

最小上界

在 Groovy 中,两种类型 AB最小上界 定义为一种类型,它

  • 超类对应于 AB 的共同超类

  • 接口对应于 AB 都实现的接口

  • 如果 AB 是原始类型,并且 A 不等于 B,则 AB 的最小上界是它们包装类型的最小上界

如果 AB 只有一个共同接口,并且它们的共同超类是 Object,则两者的 LUB 是共同接口。

最小上界表示 AB 都可以赋值到的最小类型。例如,如果 AB 都是 String,则两者的 LUB(最小上界)也是 String

class Top {}
class Bottom1 extends Top {}
class Bottom2 extends Top {}

assert leastUpperBound(String, String) == String                    (1)
assert leastUpperBound(ArrayList, LinkedList) == AbstractList       (2)
assert leastUpperBound(ArrayList, List) == List                     (3)
assert leastUpperBound(List, List) == List                          (4)
assert leastUpperBound(Bottom1, Bottom2) == Top                     (5)
assert leastUpperBound(List, Serializable) == Object                (6)
1 StringString 的 LUB 是 String
2 ArrayListLinkedList 的 LUB 是它们的共同超类型 AbstractList
3 ArrayListList 的 LUB 是它们唯一的共同接口 List
4 两个相同接口的 LUB 是接口本身
5 Bottom1Bottom2 的 LUB 是它们的超类 Top
6 两个没有任何共同点的类型的 LUB 是 Object

在这些示例中,LUB 始终可以表示为普通的、JVM 支持的类型。但 Groovy 内部将 LUB 表示为一种可能更复杂的类型,您将无法使用它来定义变量。为了说明这一点,让我们继续看这个示例

interface Foo {}
class Top {}
class Bottom extends Top implements Serializable, Foo {}
class SerializableFooImpl implements Serializable, Foo {}

BottomSerializableFooImpl 的最小上界是什么?它们没有共同的超类(除了 Object),但它们确实共享 2 个接口(SerializableFoo),所以它们的最小上界是一个代表两个接口(SerializableFoo)的联合的类型。这种类型无法在源代码中定义,但 Groovy 知道它。

在集合类型推断(以及一般的泛型类型推断)的上下文中,这变得很方便,因为组件的类型被推断为最小上界。我们可以在以下示例中说明这为什么很重要

interface Greeter { void greet() }                  (1)
interface Salute { void salute() }                  (2)

class A implements Greeter, Salute {                (3)
    void greet() { println "Hello, I'm A!" }
    void salute() { println "Bye from A!" }
}
class B implements Greeter, Salute {                (4)
    void greet() { println "Hello, I'm B!" }
    void salute() { println "Bye from B!" }
    void exit() { println 'No way!' }               (5)
}
def list = [new A(), new B()]                       (6)
list.each {
    it.greet()                                      (7)
    it.salute()                                     (8)
    it.exit()                                       (9)
}
1 Greeter 接口定义了一个方法 greet
2 Salute 接口定义了一个方法 salute
3 A 同时实现了 GreeterSalute,但没有显式接口扩展两者
4 B 也一样
5 B 定义了一个额外的 exit 方法
6 list 的类型被推断为“AB 的 LUB 列表”
7 所以可以通过 Greeter 接口调用在 AB 上都定义的 greet
8 并且可以通过 Salute 接口调用在 AB 上都定义的 salute
9 然而,调用 exit 会导致编译时错误,因为它不属于 AB 的 LUB(仅在 B 中定义)

错误消息将如下所示

[Static type checking] - Cannot find matching method Greeter or Salute#exit()

这表明 exit 方法既没有在 Greeter 上定义,也没有在 Salute 上定义,这两个接口是 AB 的最小上界中定义的。

instanceof 推断

在正常的、非类型检查的 Groovy 中,您可以这样写

class Greeter {
    String greeting() { 'Hello' }
}

void doSomething(def o) {
    if (o instanceof Greeter) {     (1)
        println o.greeting()        (2)
    }
}

doSomething(new Greeter())
1 使用 instanceof 检查来保护方法调用
2 进行调用

方法调用之所以有效,是因为动态分派(方法在运行时选择)。Java 中等效的代码需要在调用 greeting 方法之前将 o 强制转换为 Greeter,因为方法是在编译时选择的

if (o instanceof Greeter) {
    System.out.println(((Greeter)o).greeting());
}

然而,在 Groovy 中,即使您在 doSomething 方法上添加 @TypeChecked(从而激活类型检查),强制转换也 不是 必需的。编译器嵌入了 instanceof 推断,这使得强制转换成为可选。

流式类型

流类型是 Groovy 在类型检查模式下的一个重要概念,也是类型推断的扩展。其思想是,编译器能够推断代码流中变量的类型,而不仅仅是在初始化时

@groovy.transform.TypeChecked
void flowTyping() {
    def o = 'foo'                       (1)
    o = o.toUpperCase()                 (2)
    o = 9d                              (3)
    o = Math.sqrt(o)                    (4)
}
1 首先,o 使用 def 声明并赋值为 String
2 编译器推断 oString,因此允许调用 toUpperCase
3 o 被重新赋值为 double
4 调用 Math.sqrt 通过编译,因为编译器知道此时 odouble

因此,类型检查器 知道 变量的具体类型会随时间变化。特别是,如果您将最后一个赋值替换为

o = 9d
o = o.toUpperCase()

类型检查器现在将在编译时失败,因为它知道在调用 toUpperCaseodouble,所以这是一个类型错误。

重要的是要理解,触发类型推断的不是用 def 声明变量这一事实。流式类型适用于 任何 类型变量。用显式类型声明变量只会限制您可以赋值给该变量的内容

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List list = ['a','b','c']           (1)
    list = list*.toUpperCase()          (2)
    list = 'foo'                        (3)
}
1 list 声明为未检查的 List,并赋值为 String 类型的列表字面量
2 由于流式类型,此行通过编译:类型检查器知道此时 listList<String>
3 但是您不能将 String 赋值给 List,因此这是一个类型检查错误

您还可以注意到,即使变量声明 没有 泛型信息,类型检查器也知道组件类型。因此,此类代码将编译失败

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List list = ['a','b','c']           (1)
    list.add(1)                         (2)
}
1 list 被推断为 List<String>
2 因此,向 List<String> 添加 int 是一个编译时错误

修复此问题需要在声明中添加显式泛型类型

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List<? extends Serializable> list = []                      (1)
    list.addAll(['a','b','c'])                                  (2)
    list.add(1)                                                 (3)
}
1 list 声明为 List<? extends Serializable> 并使用空列表初始化
2 添加到列表中的元素符合列表的声明类型
3 因此,允许向 List<? extends Serializable> 添加 int

引入流式类型是为了缩小经典 Groovy 和静态 Groovy 之间的语义差异。特别是,考虑 Java 中这段代码的行为

public Integer compute(String str) {
    return str.length();
}
public String compute(Object o) {
    return "Nope";
}
// ...
Object string = "Some string";          (1)
Object result = compute(string);        (2)
System.out.println(result);             (3)
1 o 声明为 Object 并赋值为 String
2 我们使用 o 调用 compute 方法
3 并打印结果

在 Java 中,这段代码将输出 Nope,因为方法选择是在编译时完成的,并且基于 声明 的类型。因此,即使 o 在运行时是 String,仍然调用 Object 版本,因为 o 已声明为 Object。简而言之,在 Java 中,声明的类型最重要,无论是变量类型、参数类型还是返回类型。

在 Groovy 中,我们可以这样写

int compute(String string) { string.length() }
String compute(Object o) { "Nope" }
Object o = 'string'
def result = compute(o)
println result

但这次它将返回 6,因为方法是根据 实际 参数类型在 运行时 选择的。所以在运行时,oString,因此使用了 String 变体。请注意,这种行为与类型检查无关,它是 Groovy 通常的工作方式:动态分派。

在类型检查的 Groovy 中,我们希望确保类型检查器在 编译时 选择与运行时会选择的相同方法。由于语言的语义,这通常是不可能的,但我们可以通过流式类型做得更好。通过流式类型,当调用 compute 方法时,o推断String,因此选择了接受 String 并返回 int 的版本。这意味着我们可以推断方法的返回类型为 int,而不是 String。这对于后续调用和类型安全至关重要。

因此,在类型检查的 Groovy 中,流式类型是一个非常重要的概念,这也意味着如果应用了 @TypeChecked,方法是根据参数的 推断类型 而不是声明类型选择的。这不能确保 100% 的类型安全,因为类型检查器 可能 会选择错误的方法,但它确保了最接近动态 Groovy 的语义。

高级类型推断

结合流式类型最小上界推断用于执行高级类型推断并确保多种情况下的类型安全。特别是,程序控制结构很可能会改变变量的推断类型

class Top {
   void methodFromTop() {}
}
class Bottom extends Top {
   void methodFromBottom() {}
}
def o
if (someCondition) {
    o = new Top()                               (1)
} else {
    o = new Bottom()                            (2)
}
o.methodFromTop()                               (3)
o.methodFromBottom()  // compilation error      (4)
1 如果 someCondition 为真,则 o 被赋值为 Top
2 如果 someCondition 为假,则 o 被赋值为 Bottom
3 调用 methodFromTop 是安全的
4 但调用 methodFromBottom 不安全,所以这是一个编译时错误

当类型检查器访问 if/else 控制结构时,它会检查在 if/else 分支中分配的所有变量,并计算所有分配的最小上界。此类型是 if/else 块之后推断变量的类型,因此在此示例中,oif 分支中分配为 Top,在 else 分支中分配为 Bottom。它们的LUBTop,因此在条件分支之后,编译器将 o 推断为 Top。因此,允许调用 methodFromTop,但不允许调用 methodFromBottom

闭包中也存在相同的推理,特别是闭包共享变量。闭包共享变量是在闭包外部定义但在闭包内部使用的变量,如下例所示

def text = 'Hello, world!'                          (1)
def closure = {
    println text                                    (2)
}
1 声明一个名为 text 的变量
2 text 在闭包内部使用。它是一个 闭包共享变量

Groovy 允许开发人员使用这些变量,而无需它们是 final。这意味着闭包共享变量可以在闭包内部重新赋值

String result
doSomething { String it ->
    result = "Result: $it"
}
result = result?.toUpperCase()

问题在于闭包是一个独立的、可以随时执行(或不执行)的代码块。特别是,doSomething 可能是异步的。这意味着闭包的主体不属于主控制流。因此,类型检查器还会为每个闭包共享变量计算该变量所有赋值的LUB,并将该 LUB 用作闭包范围之外的推断类型,如下例所示

class Top {
   void methodFromTop() {}
}
class Bottom extends Top {
   void methodFromBottom() {}
}
def o = new Top()                               (1)
Thread.start {
    o = new Bottom()                            (2)
}
o.methodFromTop()                               (3)
o.methodFromBottom()  // compilation error      (4)
1 一个闭包共享变量最初被赋值为 Top
2 在闭包内部,它被赋值为 Bottom
3 允许 methodFromTop
4 methodFromBottom 是一个编译错误

这里很清楚,当调用 methodFromBottom 时,在编译时或运行时都无法保证 o 的类型 实际Bottom。虽然有可能是,但我们无法确定,因为它可能是异步的。因此,类型检查器只会允许在最小上界上进行调用,这里是 Top

6.2.6. 闭包和类型推断

类型检查器对闭包执行特殊推断,从而一方面增加了额外的检查,另一方面提高了流畅性。

返回类型推断

类型检查器能够做的第一件事是推断闭包的 返回类型。这在以下示例中简单地说明了

@groovy.transform.TypeChecked
int testClosureReturnTypeInference(String arg) {
    def cl = { "Arg: $arg" }                                (1)
    def val = cl()                                          (2)

    val.length()                                            (3)
}
1 定义了一个闭包,它返回一个字符串(更精确地说是一个 GString
2 我们调用闭包并将结果赋给一个变量
3 类型检查器推断闭包会返回一个字符串,因此允许调用 length()

如您所见,与显式声明返回类型的方法不同,闭包的返回类型无需声明:其类型是从闭包主体中推断出来的。

闭包 vs 方法

值得注意的是,返回类型推断仅适用于闭包。虽然类型检查器可以在方法上做同样的事情,但实际上这是不希望的:通常,方法可以被覆盖,并且静态地不可能确保被调用的方法不是覆盖版本。因此,流式类型实际上会认为一个方法返回某些东西,而实际上,它可能返回其他东西,如下面的示例所示

@TypeChecked
class A {
    def compute() { 'some string' }             (1)
    def computeFully() {
        compute().toUpperCase()                 (2)
    }
}
@TypeChecked
class B extends A {
    def compute() { 123 }                       (3)
}
1 A 定义了一个方法 compute,它实际返回一个 String
2 这会导致编译失败,因为 compute 的返回类型是 def(即 Object
3 B 扩展 A 并重新定义 compute,此类型返回 int

如您所见,如果类型检查器依赖于方法的推断返回类型,通过流类型,类型检查器可以确定调用toUpperCase是允许的。这实际上是一个错误,因为子类可以覆盖compute并返回一个不同的对象。这里,B#compute返回一个int,所以调用computeFullyB的实例上会看到一个运行时错误。编译器通过使用方法的声明返回类型而不是推断返回类型来防止这种情况发生。

为了保持一致性,这种行为对于每个方法都是相同的,即使它们是静态或最终的。

参数类型推断

除了返回类型,闭包还可以从上下文推断其参数类型。编译器可以通过两种方式推断参数类型

  • 通过隐式SAM类型强制转换

  • 通过API元数据

为了说明这一点,我们先从一个由于类型检查器无法推断参数类型而导致编译失败的例子开始

class Person {
    String name
    int age
}

void inviteIf(Person p, Closure<Boolean> predicate) {           (1)
    if (predicate.call(p)) {
        // send invite
        // ...
    }
}

@groovy.transform.TypeChecked
void failCompilation() {
    Person p = new Person(name: 'Gerard', age: 55)
    inviteIf(p) {                                               (2)
        it.age >= 18 // No such property: age                   (3)
    }
}
1 inviteIf方法接受一个Person和一个Closure
2 我们用一个Person和一个Closure来调用它
3 然而it在静态上不被认为是Person,编译失败

在这个例子中,闭包体包含it.age。对于动态的、未进行类型检查的代码,这将起作用,因为it的类型在运行时将是Person。不幸的是,在编译时,仅仅通过读取inviteIf的签名,无法知道it的类型。

显式闭包参数

简而言之,类型检查器没有足够的inviteIf方法的上下文信息来静态确定it的类型。这意味着方法调用需要像这样重写

inviteIf(p) { Person it ->                                  (1)
    it.age >= 18
}
1 it的类型需要显式声明

通过显式声明it变量的类型,您可以解决问题并使此代码能够进行静态检查。

从单抽象方法类型推断的参数

对于API或框架设计者来说,有两种方法可以使这对于用户更优雅,以便他们不必为闭包参数声明显式类型。第一种也是最简单的方法是用SAM类型替换闭包

interface Predicate<On> { boolean apply(On e) }                 (1)

void inviteIf(Person p, Predicate<Person> predicate) {          (2)
    if (predicate.apply(p)) {
        // send invite
        // ...
    }
}

@groovy.transform.TypeChecked
void passesCompilation() {
    Person p = new Person(name: 'Gerard', age: 55)

    inviteIf(p) {                                               (3)
        it.age >= 18                                            (4)
    }
}
1 声明一个带有apply方法的SAM接口
2 inviteIf现在使用Predicate<Person>而不是Closure<Boolean>
3 不再需要声明it变量的类型
4 it.age编译正常,it的类型是从Predicate#apply方法签名推断出来的
通过使用这种技术,我们利用了Groovy的闭包自动强制转换为SAM类型特性。您应该使用SAM类型还是闭包实际上取决于您需要做什么。在很多情况下,使用SAM接口就足够了,特别是如果您考虑Java 8中的函数式接口。然而,闭包提供了函数式接口无法访问的功能。特别是,闭包可以有委托、所有者,并且可以在调用之前作为对象进行操作(例如,克隆、序列化、柯里化等)。它们还可以支持多个签名(多态性)。因此,如果您需要这种操作,最好切换到下面描述的最先进的类型推断注解。

当涉及到闭包参数类型推断时,需要解决的原始问题,也就是说,在需要显式声明的情况下静态确定闭包参数的类型,是Groovy类型系统继承了Java类型系统,而Java类型系统不足以描述参数的类型。

@ClosureParams注解

Groovy提供了一个注解,@ClosureParams,旨在补充类型信息。此注解主要面向框架和API开发人员,他们希望通过提供类型推断元数据来扩展类型检查器的功能。如果您的库使用闭包并且您也希望获得最高水平的工具支持,这一点很重要。

让我们通过修复原始示例,引入@ClosureParams注解来演示这一点

import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FirstParam
void inviteIf(Person p, @ClosureParams(FirstParam) Closure<Boolean> predicate) {        (1)
    if (predicate.call(p)) {
        // send invite
        // ...
    }
}
inviteIf(p) {                                                                       (2)
    it.age >= 18
}
1 闭包参数用@ClosureParams注解
2 不需要为it使用显式类型,因为它被推断出来

@ClosureParams注解最少接受一个参数,该参数被称为类型提示。类型提示是一个类,它负责在编译时为闭包完成类型信息。在此示例中,使用的类型提示是groovy.transform.stc.FirstParam,它向类型检查器指示闭包将接受一个参数,其类型是方法的第一个参数的类型。在这种情况下,方法的第一个参数是Person,因此它向类型检查器指示闭包的第一个参数实际上是Person

第二个可选参数名为options。它的语义取决于类型提示类。Groovy捆绑了各种类型提示,如下表所示

表4. 预定义类型提示
类型提示 多态? 描述和示例

FirstParam
SecondParam
ThirdParam

方法的第一个(分别为第二个、第三个)参数类型

import groovy.transform.stc.FirstParam
void doSomething(String str, @ClosureParams(FirstParam) Closure c) {
    c(str)
}
doSomething('foo') { println it.toUpperCase() }
import groovy.transform.stc.SecondParam
void withHash(String str, int seed, @ClosureParams(SecondParam) Closure c) {
    c(31*str.hashCode()+seed)
}
withHash('foo', (int)System.currentTimeMillis()) {
    int mod = it%2
}
import groovy.transform.stc.ThirdParam
String format(String prefix, String postfix, String o, @ClosureParams(ThirdParam) Closure c) {
    "$prefix${c(o)}$postfix"
}
assert format('foo', 'bar', 'baz') {
    it.toUpperCase()
} == 'fooBAZbar'

FirstParam.FirstGenericType
SecondParam.FirstGenericType
ThirdParam.FirstGenericType

方法的第一个(分别为第二个、第三个)参数的第一个泛型类型

import groovy.transform.stc.FirstParam
public <T> void doSomething(List<T> strings, @ClosureParams(FirstParam.FirstGenericType) Closure c) {
    strings.each {
        c(it)
    }
}
doSomething(['foo','bar']) { println it.toUpperCase() }
doSomething([1,2,3]) { println(2*it) }

对于所有FirstParamSecondParamThirdParam类型提示,都存在SecondGenericTypeThirdGenericType的变体。

SimpleType

一种类型提示,其闭包参数的类型来自选项字符串。

import groovy.transform.stc.SimpleType
public void doSomething(@ClosureParams(value=SimpleType,options=['java.lang.String','int']) Closure c) {
    c('foo',3)
}
doSomething { str, len ->
    assert str.length() == len
}

此类型提示支持单个签名,每个参数都使用完全限定类型名称或原始类型指定为options数组的值。

MapEntryOrKeyValue

一个专门用于闭包的类型提示,闭包要么处理一个Map.Entry单参数,要么处理对应于键和值的两个参数。

import groovy.transform.stc.MapEntryOrKeyValue
public <K,V> void doSomething(Map<K,V> map, @ClosureParams(MapEntryOrKeyValue) Closure c) {
    // ...
}
doSomething([a: 'A']) { k,v ->
    assert k.toUpperCase() == v.toUpperCase()
}
doSomething([abc: 3]) { e ->
    assert e.key.length() == e.value
}

此类型提示要求第一个参数是Map类型,并从Map的实际键/值类型推断闭包参数类型。

FromAbstractTypeMethods

从某些类型的抽象方法推断闭包参数类型。为每个抽象方法推断一个签名。

import groovy.transform.stc.FromAbstractTypeMethods
abstract class Foo {
    abstract void firstSignature(int x, int y)
    abstract void secondSignature(String str)
}
void doSomething(@ClosureParams(value=FromAbstractTypeMethods, options=["Foo"]) Closure cl) {
    // ...
}
doSomething { a, b -> a+b }
doSomething { s -> s.toUpperCase() }

如果像上面的例子中那样有多个签名,类型检查器将能在每个方法的参数数量不同时推断出参数的类型。在上面的例子中,firstSignature接受2个参数,secondSignature接受1个参数,因此类型检查器可以根据参数的数量推断参数类型。但请参见接下来讨论的可选解析器类属性。

FromString

options参数推断闭包参数类型。options参数由一个逗号分隔的非原始类型数组组成。数组的每个元素对应一个签名,元素中的每个逗号分隔签名的参数。简而言之,这是最通用的类型提示,options映射中的每个字符串都被解析为签名字面量。虽然功能非常强大,但如果可以的话,应避免使用此类型提示,因为它增加了编译时间,因为需要解析类型签名。

接受String的闭包的单个签名

import groovy.transform.stc.FromString
void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) {
    // ...
}
doSomething { s -> s.toUpperCase() }
doSomething { s,i -> s.toUpperCase()*i }

一个多态闭包,接受StringString, Integer

import groovy.transform.stc.FromString
void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) {
    // ...
}
doSomething { s -> s.toUpperCase() }
doSomething { s,i -> s.toUpperCase()*i }

一个多态闭包,接受T或一对T,T

import groovy.transform.stc.FromString
public <T> void doSomething(T e, @ClosureParams(value=FromString, options=["T","T,T"]) Closure cl) {
    // ...
}
doSomething('foo') { s -> s.toUpperCase() }
doSomething('foo') { s1,s2 -> assert s1.toUpperCase() == s2.toUpperCase() }
即使您使用FirstParamSecondParamThirdParam作为类型提示,这并不严格意味着将传递给闭包的参数是方法调用的第一个(分别为第二个、第三个)参数。它只意味着闭包参数的类型将与方法调用的第一个(分别为第二个、第三个)参数的类型相同

简而言之,接受Closure的方法上缺少@ClosureParams注解不会导致编译失败。如果存在(并且它可以存在于Java源和Groovy源中),那么类型检查器将拥有更多信息并可以执行额外的类型推断。这使得此功能对框架开发人员特别有趣。

第三个可选参数名为conflictResolutionStrategy。它可以引用一个类(扩展自ClosureSignatureConflictResolver),该类可以在初始推断计算完成后找到多个参数类型时执行额外的参数类型解析。Groovy带有一个默认的类型解析器,它什么都不做,以及另一个在找到多个签名时选择第一个签名的解析器。解析器仅在找到多个签名时调用,并且是设计为后处理器。任何需要注入类型信息的语句都必须通过类型提示确定的参数签名之一。然后解析器在返回的候选签名中进行选择。

@DelegatesTo

@DelegatesTo注解被类型检查器用于推断委托的类型。它允许API设计者指示编译器委托的类型和委托策略。@DelegatesTo注解在特定章节中讨论。

6.3. 静态编译

6.3.1. 动态与静态

类型检查部分,我们已经看到Groovy通过@TypeChecked注解提供了可选的类型检查。类型检查器在编译时运行,并对动态代码执行静态分析。无论是否启用类型检查,程序的行为都将完全相同。这意味着@TypeChecked注解在程序语义方面是中立的。尽管可能需要在源代码中添加类型信息以使程序被认为是类型安全的,但最终,程序的语义是相同的。

虽然这听起来可能不错,但实际上存在一个问题:在编译时对动态代码进行类型检查,根据定义,只有在没有运行时特定行为发生时才是正确的。例如,以下程序通过了类型检查

class Computer {
    int compute(String str) {
        str.length()
    }
    String compute(int x) {
        String.valueOf(x)
    }
}

@groovy.transform.TypeChecked
void test() {
    def computer = new Computer()
    computer.with {
        assert compute(compute('foobar')) =='6'
    }
}

有两个compute方法。一个接受String并返回int,另一个接受int并返回String。如果编译此代码,它被认为是类型安全的:内部的compute('foobar')调用将返回一个int,而在此int上调用compute将反过来返回一个String

现在,在调用test()之前,考虑添加以下行

Computer.metaClass.compute = { String str -> new Date() }

使用运行时元编程,我们实际上修改了compute(String)方法的行为,使其不再返回所提供参数的长度,而是返回一个Date。如果执行程序,它将在运行时失败。由于这行代码可以从任何地方、在任何线程中添加,类型检查器绝对无法静态确保不会发生这种情况。简而言之,类型检查器容易受到猴子补丁的影响。这只是一个例子,但这说明了对动态程序进行静态分析本质上是错误的。

Groovy语言提供了@TypeChecked的替代注解,它实际上可以确保被推断为被调用的方法在运行时有效地被调用。此注解将Groovy编译器转换为静态编译器,其中所有方法调用都在编译时解析并且生成的字节码确保了这一点:该注解是@groovy.transform.CompileStatic

6.3.2. @CompileStatic注解

@CompileStatic注解可以添加到任何可以使用@TypeChecked注解的地方,即类或方法上。不需要同时添加@TypeChecked@CompileStatic,因为@CompileStatic执行@TypeChecked所做的一切,此外还触发静态编译。

让我们以上面失败的示例为例,但这次我们用@CompileStatic替换@TypeChecked注解

class Computer {
    int compute(String str) {
        str.length()
    }
    String compute(int x) {
        String.valueOf(x)
    }
}

@groovy.transform.CompileStatic
void test() {
    def computer = new Computer()
    computer.with {
        assert compute(compute('foobar')) =='6'
    }
}
Computer.metaClass.compute = { String str -> new Date() }
test()

这是唯一的区别。如果执行此程序,这次没有运行时错误。test方法变得不受猴子补丁的影响,因为在其主体中调用的compute方法在编译时链接,因此即使Computer的元类发生变化,程序仍然按照类型检查器所期望的行为。

6.3.3. 主要优势

在代码中使用@CompileStatic有以下几个好处

性能改进取决于您正在执行的程序类型。如果是I/O密集型,静态编译代码和动态代码之间的差异几乎不明显。在CPU密集型代码上,由于生成的字节码与Java为等效程序生成的字节码非常接近(如果不是相等),性能会大大提高。

使用Groovy的invokedynamic版本(JDK 7及更高版本用户可以使用),动态代码的性能应该非常接近静态编译代码的性能。有时,它甚至可能更快!只有一种方法可以确定您应该选择哪个版本:测量。原因是根据您的程序您使用的JVM,性能可能会有显著差异。特别是,Groovy的invokedynamic版本对正在使用的JVM版本非常敏感。

7. 类型检查扩展

7.1. 编写类型检查扩展

7.1.1. 迈向更智能的类型检查器

尽管Groovy是一种动态语言,但它可以在编译时与静态类型检查器一起使用,通过@TypeChecked注解启用。在此模式下,编译器变得更加详细,并会抛出错误,例如拼写错误、不存在的方法等。不过,这也有一些限制,其中大部分来自于Groovy本质上仍然是一种动态语言的事实。例如,您无法对使用标记构建器的代码进行类型检查

def builder = new MarkupBuilder(out)
builder.html {
    head {
        // ...
    }
    body {
        p 'Hello, world!'
    }
}

在前面的示例中,htmlheadbodyp方法都不存在。但是,如果执行代码,它会起作用,因为Groovy使用动态调度并在运行时转换这些方法调用。在此构建器中,对可以使用多少标签或属性没有限制,这意味着类型检查器无法在编译时知道所有可能的方法(标签),除非您创建一个专门用于HTML的构建器。

Groovy是实现内部DSL的首选平台。灵活的语法,结合运行时和编译时元编程能力,使Groovy成为一个有趣的选择,因为它允许程序员专注于DSL而不是工具或实现。由于Groovy DSL是Groovy代码,因此可以轻松获得IDE支持,而无需编写专门的插件。

在很多情况下,DSL引擎是用Groovy(或Java)编写的,然后用户代码作为脚本执行,这意味着您在用户逻辑之上有一些包装器。包装器可能包括,例如,一个GroovyShellGroovyScriptEngine,它在运行脚本之前透明地执行一些任务(添加导入、应用AST转换、扩展基本脚本等)。通常,用户编写的脚本未经测试就投入生产,因为DSL逻辑达到了**任何**用户都可以使用DSL语法编写代码的地步。最终,用户可能只是忽略他们编写的实际上是**代码**。这给DSL实现者带来了一些挑战,例如保护用户代码的执行,或者在本例中,提前报告错误。

例如,想象一个DSL,其目标是远程驾驶火星车。向火星车发送消息大约需要15分钟。如果火星车执行脚本并因错误(例如拼写错误)而失败,您将面临两个问题

  • 首先,反馈只在30分钟后才出现(火星车获取脚本所需的时间和接收错误所需的时间)

  • 其次,脚本的一部分已经被执行,您可能需要大幅更改固定脚本(这意味着您需要知道火星车的当前状态……)

类型检查扩展是一种机制,它允许DSL引擎的开发人员通过应用与常规 Groovy 类相同的静态类型检查来使这些脚本更安全。

这里的原则是尽早失败,也就是说,尽快使脚本编译失败,如果可能,向用户提供反馈(包括友好的错误消息)。

简而言之,类型检查扩展背后的想法是让编译器了解DSL使用的所有运行时元编程技巧,以便脚本可以受益于与冗长的静态编译代码相同的编译时检查级别。我们将看到您甚至可以通过执行正常类型检查器不会执行的检查来进一步为您的用户提供强大的编译时检查。

7.1.2. 扩展属性

@TypeChecked 注解支持一个名为 extensions 的属性。此参数接受一个字符串数组,对应于类型检查扩展脚本列表。这些脚本在编译时在类路径中找到。例如,您可以编写

@TypeChecked(extensions='/path/to/myextension.groovy')
void foo() { ...}

在这种情况下,foo 方法将根据正常类型检查器的规则进行类型检查,并由myextension.groovy脚本中的规则补充。请注意,虽然类型检查器内部支持多种机制来实现类型检查扩展(包括纯旧 Java 代码),但推荐的方法是使用这些类型检查扩展脚本。

7.1.3. 类型检查的 DSL

类型检查扩展背后的想法是使用 DSL 来扩展类型检查器的功能。这个 DSL 允许您通过“事件驱动”API 介入编译过程,更具体地说是类型检查阶段。例如,当类型检查器进入方法体时,它会抛出 beforeVisitMethod 事件,扩展可以对此做出反应

beforeVisitMethod { methodNode ->
 println "Entering ${methodNode.name}"
}

假设您手头有这个火星车 DSL。用户将编写

robot.move 100

如果您有一个类定义如下

class Robot {
    Robot move(int qt) { this }
}

脚本可以在执行前使用以下脚本进行类型检查

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(TypeChecked)            (1)
)
def shell = new GroovyShell(config)                         (2)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)                                      (3)
1 编译器配置将@TypeChecked注解添加到所有类
2 GroovyShell中使用配置
3 因此使用shell编译的脚本在没有用户显式添加@TypeChecked的情况下进行编译

使用上述编译器配置,我们可以透明地将@TypeChecked应用于脚本。在这种情况下,它将在编译时失败

[Static type checking] - The variable [robot] is undeclared.

现在,我们将稍微更新配置,以包含“extensions”参数

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        TypeChecked,
        extensions:['robotextension.groovy'])
)

然后将以下内容添加到您的类路径

robotextension.groovy
unresolvedVariable { var ->
    if ('robot'==var.name) {
        storeType(var, classNodeFor(Robot))
        handled = true
    }
}

这里,我们告诉编译器,如果找到了一个未解析的变量,并且该变量的名称是robot,那么我们可以确保该变量的类型是Robot

7.1.4. 类型检查扩展 API

抽象语法树 (AST)

类型检查 API 是一个底层 API,处理抽象语法树。即使 DSL 使其比仅仅处理纯 Java 或 Groovy 中的 AST 代码容易得多,您也必须非常了解 AST 才能开发扩展。

事件

类型检查器发送以下事件,扩展脚本可以对此做出反应

事件名称

setup

何时调用

类型检查器完成初始化后调用

参数

用法

setup {
    // this is called before anything else
}

可用于执行扩展的设置

事件名称

finish

何时调用

类型检查器完成类型检查后调用

参数

用法

finish {
    // this is after completion
    // of all type checking
}

可以在类型检查器完成其工作后执行额外的检查。

事件名称

unresolvedVariable

何时调用

当类型检查器发现未解析的变量时调用

参数

VariableExpression vexp

用法

unresolvedVariable { VariableExpression vexp ->
    if (vexp.name == 'people') {
        storeType(vexp, LIST_TYPE)
        handled = true
    }
}

允许开发人员协助类型检查器处理用户注入的变量。

事件名称

unresolvedProperty

何时调用

当类型检查器无法在接收器上找到属性时调用

参数

PropertyExpression pexp

用法

unresolvedProperty { PropertyExpression pexp ->
    if (pexp.propertyAsString == 'longueur' &&
            getType(pexp.objectExpression) == STRING_TYPE) {
        storeType(pexp, int_TYPE)
        handled = true
    }
}

允许开发人员处理“动态”属性

事件名称

unresolvedAttribute

何时调用

当类型检查器无法在接收器上找到属性时调用

参数

AttributeExpression aexp

用法

unresolvedAttribute { AttributeExpression aexp ->
    if (getType(aexp.objectExpression) == STRING_TYPE) {
        storeType(aexp, STRING_TYPE)
        handled = true
    }
}

允许开发人员处理缺失的属性

事件名称

beforeMethodCall

何时调用

在类型检查器开始类型检查方法调用之前调用

参数

MethodCall call

用法

beforeMethodCall { call ->
    if (isMethodCallExpression(call)
            && call.methodAsString=='toUpperCase') {
        addStaticTypeError('Not allowed',call)
        handled = true
    }
}

允许您在类型检查器执行自己的检查之前拦截方法调用。如果您希望在有限范围内用自定义检查替换默认类型检查,这很有用。在这种情况下,您必须将handled标志设置为true,以便类型检查器跳过自己的检查。

事件名称

afterMethodCall

何时调用

类型检查器完成方法调用类型检查后调用

参数

MethodCall call

用法

afterMethodCall { call ->
    if (getTargetMethod(call).name=='toUpperCase') {
        addStaticTypeError('Not allowed',call)
        handled = true
    }
}

允许您在类型检查器完成其自身检查后执行额外检查。如果您希望执行标准类型检查测试,但同时又想确保额外的类型安全,例如检查参数之间的关系,这尤其有用。请注意,即使您执行了beforeMethodCall并将处理标志设置为true,afterMethodCall也会被调用。

事件名称

onMethodSelection

何时调用

当类型检查器找到适合方法调用的方法时调用

参数

Expression expr, MethodNode node

用法

onMethodSelection { expr, node ->
    if (node.declaringClass.name == 'java.lang.String') {
        // calling a method on 'String'
        // let’s perform additional checks!
        if (++count>2) {
            addStaticTypeError("You can use only 2 calls on String in your source code",expr)
        }
    }
}

类型检查器通过推断方法调用的参数类型,然后选择目标方法来工作。如果找到对应的方法,它将触发此事件。例如,如果您想对特定的方法调用做出反应,例如进入一个接受闭包作为参数的方法(如构建器)的范围,这很有趣。请注意,此事件可能针对各种类型的表达式抛出,而不仅仅是方法调用(例如二进制表达式)。

事件名称

methodNotFound

何时调用

当类型检查器无法找到适合方法调用的方法时调用

参数

ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes,MethodCall call

用法

methodNotFound { receiver, name, argList, argTypes, call ->
    // receiver is the inferred type of the receiver
    // name is the name of the called method
    // argList is the list of arguments the method was called with
    // argTypes is the array of inferred types for each argument
    // call is the method call for which we couldn’t find a target method
    if (receiver==classNodeFor(String)
            && name=='longueur'
            && argList.size()==0) {
        handled = true
        return newMethod('longueur', classNodeFor(String))
    }
}

onMethodSelection不同,此事件在类型检查器无法为方法调用(实例或静态)找到目标方法时发送。它为您提供了在错误发送给用户之前拦截错误的机会,也可以设置目标方法。为此,您需要返回一个MethodNode列表。在大多数情况下,您将返回:一个空列表,表示您没有找到相应的方法;一个只包含一个元素的列表,表示目标方法毫无疑问。如果您返回多个MethodNode,那么编译器将向用户抛出错误,说明方法调用不明确,并列出可能的方法。为方便起见,如果您只想返回一个方法,则可以直接返回它,而不是将其包装在列表中。

事件名称

beforeVisitMethod

何时调用

类型检查器在类型检查方法体之前调用

参数

MethodNode node

用法

beforeVisitMethod { methodNode ->
    // tell the type checker we will handle the body by ourselves
    handled = methodNode.name.startsWith('skip')
}

类型检查器将在开始类型检查方法体之前调用此方法。例如,如果您想自己执行类型检查而不是让类型检查器执行,您必须将 handled 标志设置为 true。此事件还可以用于帮助定义扩展的范围(例如,仅当您在方法 foo 内部时才应用它)。

事件名称

afterVisitMethod

何时调用

类型检查器在类型检查方法体之后调用

参数

MethodNode node

用法

afterVisitMethod { methodNode ->
    scopeExit {
        if (methods>2) {
            addStaticTypeError("Method ${methodNode.name} contains more than 2 method calls", methodNode)
        }
    }
}

让您有机会在类型检查器访问方法体后执行额外的检查。这在您收集信息(例如)并希望在收集完所有信息后执行额外检查时很有用。

事件名称

beforeVisitClass

何时调用

类型检查器在类型检查类之前调用

参数

ClassNode node

用法

beforeVisitClass { ClassNode classNode ->
    def name = classNode.nameWithoutPackage
    if (!(name[0] in 'A'..'Z')) {
        addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode)
    }
}

如果一个类被类型检查,那么在访问该类之前,这个事件将被发送。对于在带有@TypeChecked注解的类中定义的内部类,也是如此。它可以帮助您定义扩展的范围,甚至可以完全用自定义类型检查实现替换类型检查器的访问。为此,您需要将handled标志设置为true

事件名称

afterVisitClass

何时调用

类型检查器完成对类型检查类的访问后调用

参数

ClassNode node

用法

afterVisitClass { ClassNode classNode ->
    def name = classNode.nameWithoutPackage
    if (!(name[0] in 'A'..'Z')) {
        addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode)
    }
}

在类型检查器完成工作后,为每个被类型检查的类调用。这包括带有@TypeChecked注解的类以及在同一类中定义的所有未跳过的内部/匿名类。

事件名称

incompatibleAssignment

何时调用

当类型检查器认为赋值不正确时调用,这意味着赋值的右侧与左侧不兼容

参数

ClassNode lhsType, ClassNode rhsType, Expression assignment

用法

incompatibleAssignment { lhsType, rhsType, expr ->
    if (isBinaryExpression(expr) && isAssignment(expr.operation.type)) {
        if (lhsType==classNodeFor(int) && rhsType==classNodeFor(Closure)) {
            handled = true
        }
    }
}

赋予开发者处理不正确赋值的能力。例如,如果一个类覆盖了setProperty,这很有用,因为在这种情况下,将一种类型的变量赋值给另一种类型的属性可以通过运行时机制处理。在这种情况下,您可以通过告诉类型检查器赋值是有效的(将handled设置为true)来帮助它。

事件名称

incompatibleReturnType

何时调用

当类型检查器认为返回值与 enclosing 闭包或方法的返回类型不兼容时调用

参数

ReturnStatement statement, ClassNode valueType

用法

incompatibleReturnType { stmt, type ->
    if (type == STRING_TYPE) {
        handled = true
    }
}

赋予开发者处理不正确返回值的能力。例如,当返回值将进行隐式转换或包围闭包的目标类型难以正确推断时,这很有用。在这种情况下,您可以通过告诉类型检查器赋值是有效的(通过设置handled属性)来帮助它。

事件名称

ambiguousMethods

何时调用

当类型检查器无法在多个候选方法之间进行选择时调用

参数

List<MethodNode> methods,  Expression origin

用法

ambiguousMethods { methods, origin ->
    // choose the method which has an Integer as parameter type
    methods.find { it.parameters.any { it.type == classNodeFor(Integer) } }
}

赋予开发者处理不正确赋值的能力。例如,如果一个类覆盖了setProperty,这很有用,因为在这种情况下,将一种类型的变量赋值给另一种类型的属性可以通过运行时机制处理。在这种情况下,您可以通过告诉类型检查器赋值是有效的(将handled设置为true)来帮助它。

当然,一个扩展脚本可以包含多个块,并且您可以有多个块响应同一个事件。这使得 DSL 看起来更美观,更容易编写。然而,响应事件远远不够。如果您知道可以响应事件,您还需要处理错误,这意味着需要一些helper方法来简化操作。

7.1.5. 使用扩展

支持类

DSL 依赖于一个名为org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport 的支持类。该类本身扩展了org.codehaus.groovy.transform.stc.TypeCheckingExtension。这两个类定义了许多helper方法,这些方法将使使用 AST 变得更容易,尤其是在类型检查方面。一个值得注意的有趣之处在于,您可以访问类型检查器。这意味着您可以以编程方式调用类型检查器的方法,包括那些允许您抛出编译错误的方法。

扩展脚本委托给org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport类,这意味着您可以直接访问以下变量

类型检查上下文包含许多对类型检查器上下文有用的信息。例如,封闭方法调用、二进制表达式、闭包等的当前堆栈。如果您必须知道发生错误时您身在何处以及您想处理它,这些信息尤其重要。

除了GroovyTypeCheckingExtensionSupportStaticTypeCheckingVisitor提供的功能外,类型检查 DSL 脚本还导入了org.codehaus.groovy.ast.ClassHelperorg.codehaus.groovy.transform.stc.StaticTypeCheckingSupport的静态成员,从而通过OBJECT_TYPESTRING_TYPETHROWABLE_TYPE等提供对常见类型的访问,以及missesGenericsTypes(ClassNode)isClassClassNodeWrappingConcreteType(ClassNode)等检查。

类节点

当您使用类型检查扩展时,处理类节点需要特别注意。编译使用抽象语法树 (AST),当您类型检查类时,该树可能不完整。这也意味着当您引用类型时,您不能使用像StringHashSet这样的类字面量,而应该使用表示这些类型的类节点。这需要一定程度的抽象和理解 Groovy 如何处理类节点。为了使事情更容易,Groovy 提供了几个帮助方法来处理类节点。例如,如果您想表达“String 的类型”,您可以编写

assert classNodeFor(String) instanceof ClassNode

您还会注意到,classNodeFor 有一个接受String作为参数的变体,而不是Class。一般来说,您不应该使用那个,因为它会创建一个名为String的类节点,但它没有定义任何方法、属性等。第一个版本返回一个已解析的类节点,而第二个版本返回一个未解析的类节点。所以后者应该保留给非常特殊的情况。

您可能遇到的第二个问题是引用尚未编译的类型。这可能比您想象的更频繁。例如,当您同时编译一组文件时。在这种情况下,如果您想说“该变量是Foo类型”,但Foo尚未编译,您仍然可以使用lookupClassNodeFor引用Foo类节点

assert lookupClassNodeFor('Foo') instanceof ClassNode
帮助类型检查器

假设您知道变量fooFoo类型,并且您想将此信息告知类型检查器。那么您可以使用storeType方法,该方法接受两个参数:第一个是要存储类型的节点,第二个是节点的类型。如果您查看storeType的实现,您会看到它委托给类型检查器等效方法,该方法本身做了大量工作来存储节点元数据。您还会看到存储类型不仅限于变量:您可以设置任何表达式的类型。

同样,获取 AST 节点的类型只需在该节点上调用getType。这通常是您想要的,但您必须了解一些事情

  • getType返回表达式的推断类型。这意味着对于声明为Object类型的变量,它不会返回Object的类节点,而是该变量在代码的此时的推断类型(流类型)

  • 如果您想访问变量(或字段/参数)的原始类型,则必须在 AST 节点上调用相应的方法

抛出错误

要抛出类型检查错误,只需调用addStaticTypeError方法,该方法接受两个参数

  • 一个消息,是一个将显示给最终用户的字符串

  • 一个导致错误的AST节点。最好提供最合适的AST节点,因为它将用于检索行号和列号

isXXXExpression

通常需要知道 AST 节点的类型。为了可读性,DSL 提供了一个特殊的 isXXXExpression 方法,它将委托给 x instance of XXXExpression。例如,您无需编写

if (node instanceof BinaryExpression) {
   ...
}

您可以直接写

if (isBinaryExpression(node)) {
   ...
}
虚拟方法

当您对动态代码执行类型检查时,您可能会经常遇到这样的情况:您知道方法调用是有效的,但它背后没有“真实”的方法。例如,以 Grails 动态查找器为例。您可以有一个方法调用,其中包含一个名为findByName(…)的方法。由于 bean 中没有定义findByName方法,类型检查器会抱怨。然而,您会知道该方法在运行时不会失败,您甚至可以判断该方法的返回类型。对于这种情况,DSL 支持两种特殊的构造,即phantom methods。这意味着您将返回一个实际上不存在但定义在类型检查上下文中的方法节点。存在三种方法

  • newMethod(String name, Class returnType)

  • newMethod(String name, ClassNode returnType)

  • newMethod(String name, Callable<ClassNode> return Type)

这三个变体都做同样的事情:它们创建一个新的方法节点,其名称是提供的名称,并定义此方法的返回类型。此外,类型检查器会将这些方法添加到generatedMethods列表中(参见下面的isGenerated)。我们只设置名称和返回类型的原因是,在90%的情况下,您只需要这些。例如,在上面的findByName示例中,您唯一需要知道的是findByName在运行时不会失败,并且它返回一个域类。返回类型的Callable版本很有趣,因为它在类型检查器实际需要时才推迟返回类型的计算。这很有趣,因为在某些情况下,当类型检查器需要时,您可能不知道实际的返回类型,因此您可以使用一个闭包,每次类型检查器在此方法节点上调用getReturnType时都会调用该闭包。如果将其与延迟检查结合,您可以实现相当复杂的类型检查,包括处理前向引用。

newMethod(name) {
    // each time getReturnType on this method node will be called, this closure will be called!
    println 'Type checker called me!'
    lookupClassNodeFor(Foo) // return type
}

如果您需要的不仅仅是名称和返回类型,您总是可以自己创建一个新的MethodNode

作用域

作用域在 DSL 类型检查中非常重要,也是我们无法使用基于切面的方法进行 DSL 类型检查的原因之一。基本上,您必须能够非常精确地定义您的扩展何时适用,何时不适用。此外,您必须能够处理普通类型检查器无法处理的情况,例如前向引用

point a(1,1)
line a,b // b is referenced afterwards!
point b(5,2)

例如,假设您要处理一个构建器

builder.foo {
   bar
   baz(bar)
}

那么您的扩展应该只在您进入foo方法后才激活,并在此范围之外不激活。但是您可能会遇到复杂的情况,例如同一个文件中的多个构建器或嵌入式构建器(构建器中的构建器)。虽然您不应该试图从一开始就解决所有这些问题(您必须接受类型检查的限制),但类型检查器确实提供了一个很好的机制来处理这个问题:一个作用域堆栈,使用newScopescopeExit方法。

  • newScope创建一个新作用域并将其放到堆栈顶部

  • scopeExits从堆栈中弹出一个作用域

一个作用域包括

  • 一个父作用域

  • 一个自定义数据映射

如果你想看实现,它只是一个LinkedHashMap (org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport.TypeCheckingScope),但它功能强大。例如,你可以使用这样的作用域来存储一个闭包列表,以便在退出作用域时执行。这就是你处理前向引用的方式:

def scope = newScope()
scope.secondPassChecks = []
//...
scope.secondPassChecks << { println 'executed later' }
// ...
scopeExit {
    secondPassChecks*.run() // execute deferred checks
}

也就是说,如果某个时候你无法确定表达式的类型,或者你无法在此时检查赋值是否有效,你仍然可以在以后进行检查……这是一个非常强大的功能。现在,newScopescopeExit提供了一些有趣的语法糖

newScope {
    secondPassChecks = []
}

在 DSL 中,您可以随时使用getCurrentScope()或更简单地使用currentScope访问当前作用域

//...
currentScope.secondPassChecks << { println 'executed later' }
// ...

通用模式将是

  • 确定一个切入点,在此处将新作用域推入堆栈,并在该作用域内初始化自定义变量

  • 利用各种事件,您可以使用自定义作用域中存储的信息执行检查、延迟检查等。

  • 确定一个切入点,在此处退出作用域,调用scopeExit并最终执行额外检查

其他有用的方法

有关辅助方法的完整列表,请参阅org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupportorg.codehaus.groovy.transform.stc.TypeCheckingExtension 类。然而,请特别注意这些方法

  • isDynamic:接受一个VariableExpression作为参数,如果变量是DynamicExpression则返回true,这意味着在脚本中它没有使用类型或def定义。

  • isGenerated:接受一个MethodNode作为参数,并判断该方法是否是类型检查器扩展使用newMethod方法生成的

  • isAnnotatedBy:接受一个AST节点和一个Class(或ClassNode),并判断该节点是否被此Class注解。例如:isAnnotatedBy(node, NotNull)

  • getTargetMethod:接受一个方法调用作为参数,并返回类型检查器为其确定的MethodNode

  • delegatesTo:模拟@DelegatesTo注解的行为。它允许您告诉参数将委托给特定类型(您也可以指定委托策略)

7.2. 高级类型检查扩展

7.2.1. 预编译类型检查扩展

以上所有示例都使用类型检查脚本。它们以源代码形式在类路径中找到,这意味着

  • 与类型检查扩展对应的 Groovy 源文件在编译类路径中可用

  • 此文件由 Groovy 编译器为每个正在编译的源单元(通常,一个源单元对应一个文件)编译

这是一种开发类型检查扩展非常方便的方式,但它意味着更慢的编译阶段,因为每次编译每个文件时,扩展本身也需要编译。出于这些原因,依赖预编译扩展可能更实用。您有两种选择来实现这一点

  • 用Groovy编写扩展,编译它,然后使用对扩展类的引用而不是源

  • 用Java编写扩展,编译它,然后使用对扩展类的引用

用Groovy编写类型检查扩展是最简单的途径。基本上,其思想是类型检查扩展脚本成为类型检查类主方法的主体,如下所示

import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport

class PrecompiledExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {     (1)
    @Override
    Object run() {                                                                          (2)
        unresolvedVariable { var ->
            if ('robot'==var.name) {
                storeType(var, classNodeFor(Robot))                                         (3)
                handled = true
            }
        }
    }
}
1 扩展TypeCheckingDSL类是最简单的
2 然后扩展代码需要放在run方法内部
3 并且您可以使用与源代码形式编写的扩展相同的事件

设置扩展与使用源代码形式的扩展非常相似

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        TypeChecked,
        extensions:['typing.PrecompiledExtension'])
)

区别在于,您只需指定预编译扩展的完全限定类名,而不是使用类路径中的路径。

如果您真的想用 Java 编写扩展,那么您将无法从类型检查扩展 DSL 中受益。上面的扩展可以用 Java 这样重写

import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.transform.stc.AbstractTypeCheckingExtension;


import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;

public class PrecompiledJavaExtension extends AbstractTypeCheckingExtension {                   (1)

    public PrecompiledJavaExtension(final StaticTypeCheckingVisitor typeCheckingVisitor) {
        super(typeCheckingVisitor);
    }

    @Override
    public boolean handleUnresolvedVariableExpression(final VariableExpression vexp) {          (2)
        if ("robot".equals(vexp.getName())) {
            storeType(vexp, ClassHelper.make(Robot.class));
            setHandled(true);
            return true;
        }
        return false;
    }

}
1 扩展AbstractTypeCheckingExtension
2 然后根据需要覆盖handleXXX方法

7.2.2. 在类型检查扩展中使用@Grab

在类型检查扩展中使用@Grab注解是完全可行的。这意味着您可以包含只在编译时可用的库。在这种情况下,您必须明白您将显著增加编译时间(至少在首次抓取依赖项时)。

7.2.3. 共享或打包类型检查扩展

类型检查扩展只是一个需要放在类路径中的脚本。因此,您可以直接共享它,或者将其打包成一个jar文件并添加到类路径中。

7.2.4. 全局类型检查扩展

虽然您可以配置编译器透明地将类型检查扩展添加到您的脚本中,但目前没有办法仅仅通过将其放在类路径中来透明地应用扩展。

7.2.5. 类型检查扩展和@CompileStatic

类型检查扩展与@TypeChecked一起使用,但也可以与@CompileStatic一起使用。然而,您必须注意

  • @CompileStatic一起使用的类型检查扩展通常不足以让编译器知道如何从“不安全”代码生成静态可编译代码

  • 可以与@CompileStatic一起使用类型检查扩展来增强类型检查,也就是说引入更多编译错误,而不实际处理动态代码

我们来解释第一点,即即使使用扩展,编译器也无法知道如何静态编译您的代码:从技术上讲,即使您告诉类型检查器动态变量的类型,它也无法知道如何编译它。它是getBinding('foo')getProperty('foo')delegate.getFoo(),…?即使您使用类型检查扩展(再次强调,它只会给出类型提示),也绝对没有直接的方法可以告诉静态编译器如何编译此类代码。

对于这个特殊例子,一个可能的解决方案是指示编译器使用混合模式编译。更高级的解决方案是使用类型检查期间的AST转换,但这要复杂得多。

类型检查扩展允许您在类型检查器失败时提供帮助,但它也允许您在类型检查器不失败时失败。在这种情况下,为@CompileStatic支持扩展也是有意义的。想象一个能够类型检查SQL查询的扩展。在这种情况下,该扩展在动态和静态上下文中都有效,因为没有该扩展,代码仍然会通过。

7.2.6. 混合模式编译

在上一节中,我们强调了您可以使用@CompileStatic激活类型检查扩展。在这种情况下,类型检查器将不再抱怨一些未解析的变量或未知的方法调用,但它仍然不知道如何静态编译它们。

混合模式编译提供了第三种方式,即指示编译器,每当发现未解析的变量或方法调用时,它应该回退到动态模式。这可以通过类型检查扩展和特殊的makeDynamic调用来实现。

为了说明这一点,让我们回到Robot示例

robot.move 100

让我们尝试使用@CompileStatic而不是@TypeChecked来激活我们的类型检查扩展

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        CompileStatic,                                      (1)
        extensions:['robotextension.groovy'])               (2)
)
def shell = new GroovyShell(config)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)
1 透明地应用@CompileStatic
2 激活类型检查扩展

脚本将正常运行,因为静态编译器知道robot变量的类型,因此它能够直接调用move。但在此之前,编译器如何知道如何获取robot变量?实际上,默认情况下,在类型检查扩展中,将未解析变量上的handled=true设置为会自动触发动态解析,因此在这种情况下,您无需做任何特殊操作即可让编译器使用混合模式。然而,让我们稍微更新一下我们的示例,从robot脚本开始

move 100

在这里您可以看到不再引用robot了。我们的扩展将无济于事,因为我们将无法指示编译器move是在Robot实例上完成的。这个代码示例可以通过groovy.util.DelegatingScript的帮助以完全动态的方式执行

def config = new CompilerConfiguration()
config.scriptBaseClass = 'groovy.util.DelegatingScript'     (1)
def shell = new GroovyShell(config)
def runner = shell.parse(script)                            (2)
runner.setDelegate(new Robot())                             (3)
runner.run()                                                (4)
1 我们配置编译器使用DelegatingScript作为基类
2 脚本源需要被解析,并返回一个DelegatingScript实例
3 然后我们可以调用setDelegate来使用Robot作为脚本的委托
4 然后执行脚本。move将直接在委托上执行

如果我们要使其通过@CompileStatic,则必须使用类型检查扩展,因此让我们更新配置

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        CompileStatic,                                      (1)
        extensions:['robotextension2.groovy'])              (2)
)
1 透明地应用@CompileStatic
2 使用旨在识别对move调用的替代类型检查扩展

在上一节中,我们学习了如何处理无法识别的方法调用,因此我们能够编写此扩展

robotextension2.groovy
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call)                        (1)
        && call.implicitThis                                (2)
        && 'move'==name                                     (3)
        && argTypes.length==1                               (4)
        && argTypes[0] == classNodeFor(int)                 (5)
    ) {
        handled = true                                      (6)
        newMethod('move', classNodeFor(Robot))              (7)
    }
}
1 如果调用是一个方法调用(而不是静态方法调用)
2 此调用是在“隐式this”上进行的(没有显式this.
3 被调用的方法是move
4 并且调用只有一个参数
5 并且该参数的类型为int
6 然后告诉类型检查器该调用是有效的
7 并且调用的返回类型是Robot

如果您尝试执行此代码,那么您可能会惊讶地发现它实际上在运行时失败了

java.lang.NoSuchMethodError: java.lang.Object.move()Ltyping/Robot;

原因很简单:虽然类型检查扩展对于不涉及静态编译的@TypeChecked来说是足够的,但对于需要额外信息的@CompileStatic来说却不够。在这种情况下,您告诉编译器方法存在,但您没有向它解释它实际上是什么方法,以及消息的接收者(委托)是什么。

修复这个问题非常简单,只需用其他东西替换newMethod调用

robotextension3.groovy
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call)
        && call.implicitThis
        && 'move'==name
        && argTypes.length==1
        && argTypes[0] == classNodeFor(int)
    ) {
        makeDynamic(call, classNodeFor(Robot))              (1)
    }
}
1 告诉编译器该调用应动态进行

makeDynamic调用执行3件事

  • 它返回一个像newMethod一样的虚拟方法

  • 自动为您将handled标志设置为true

  • 但也会将call标记为动态执行

因此,当编译器必须为对move的调用生成字节码时,由于它现在被标记为动态调用,它将回退到动态编译器并让它处理该调用。并且由于扩展告诉我们动态调用的返回类型是Robot,所以后续调用将静态执行!

有人会想为什么静态编译器默认不这样做,而不需要扩展。这是一个设计决策

  • 如果代码是静态编译的,我们通常希望类型安全和最佳性能

  • 因此,如果将无法识别的变量/方法调用动态化,您将失去类型安全性,并且还会失去编译时所有对拼写错误的检查!

简而言之,如果您希望进行混合模式编译,则必须通过类型检查扩展显式地进行,以便编译器和 DSL 设计者完全了解他们在做什么。

makeDynamic可用于3种AST节点

  • 方法节点(MethodNode

  • 变量(VariableExpression

  • 属性表达式(PropertyExpression

如果这还不够,那就意味着静态编译无法直接完成,您必须依赖AST转换。

7.2.7. 在扩展中转换 AST

从AST转换设计的角度来看,类型检查扩展非常有吸引力:扩展可以访问推断类型等上下文信息,这通常很有用。并且扩展可以直接访问抽象语法树。既然您可以访问AST,理论上没有什么能阻止您修改AST。但是,除非您是高级AST转换设计者并且非常了解编译器内部,否则我们不建议您这样做

  • 首先,您将明确违反类型检查的契约,即只注解 AST。类型检查不应该修改 AST 树,因为您将无法再保证没有@TypeChecked注解的代码与没有注解的代码行为相同。

  • 如果您的扩展旨在与@CompileStatic一起工作,那么您可以修改AST,因为这确实是@CompileStatic最终会做的事情。静态编译不能保证与动态Groovy相同的语义,因此用@CompileStatic编译的代码和用@TypeChecked编译的代码之间确实存在差异。由您选择任何您想更新AST的策略,但可能使用在类型检查之前运行的AST转换更容易。

  • 如果您不能依赖于在类型检查器之前启动的转换,那么您必须非常小心

类型检查阶段是字节码生成之前编译器中运行的最后一个阶段。所有其他 AST 转换都在此之前运行,并且编译器在“修复”类型检查阶段之前生成的错误 AST 方面做得非常好。一旦您在类型检查期间执行转换,例如直接在类型检查扩展中执行,那么您就必须自己完成所有生成100%符合编译器规范的抽象语法树的工作,这很容易变得复杂。这就是为什么如果您刚开始使用类型检查扩展和 AST 转换,我们不建议走这条路。

7.2.8. 示例

真实的类型检查扩展示例很容易找到。您可以下载 Groovy 的源代码并查看 TypeCheckingExtensionsTest 类,该类链接到 各种扩展脚本

一个复杂的类型检查扩展示例可以在Markup Template Engine源代码中找到:此模板引擎依赖于类型检查扩展和AST转换,将模板转换为完全静态编译的代码。源代码可以在这里找到。