本章介绍 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 值是类的实例,则类案例值匹配

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

  • 如果 switch 值包含在集合中,则集合案例值匹配。这也包括范围(因为它们是 List)

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

  • 如果以上条件均不满足,则如果案例值等于 switch 值,则案例值匹配

当使用闭包案例值时,默认的 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 自 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. 多重 catch

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

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 共享 assert 关键字不同,Groovy 中的后者表现得非常不同。首先,Groovy 中的断言始终会执行,与 JVM 的 -ea 标志无关。这使其成为单元测试的首选。 “强大断言”的概念与 Groovy assert 的行为直接相关。

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

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

断言的结果与您在 Java 中获得的结果大相径庭。如果断言为 true,则不发生任何事情。如果断言为 false,则它提供被断言表达式中每个子表达式的值的可视化表示。例如

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 就是这样做的,以使测试更容易。

2. 表达式

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

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

表 1. 像 Java 一样的表达式

示例表达式

描述

foo

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

this, super, it

特殊名称

true, 10, "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. 特殊表达式

示例表达式

描述

字符串

缩写类字面量(不含歧义时)

{ 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. GPath 用于 XML 导航

这是一个 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. 匹配器

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

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

5.4. 迭代器和枚举

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

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

5.5. Maps

非空 Maps 被评估为 true。

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

5.6. 字符串

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

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

5.7. 数字

非零数字为真。

assert 1
assert 3.5
assert !0

5.8. 对象引用

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

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()
  • TString, boolean, BooleanClass 之一

    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 b1 = (byte) 4

6.2.3. List 和 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 方法,但找不到。它将以以下消息编译失败

找不到匹配的方法 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 中有三种原生集合字面量

  • 列表,使用 [] 字面量

  • 映射,使用 [:] 字面量

  • 范围,使用 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 的类型被推断为“列表 A 和 B 的 LUB”
7 因此可以通过 Greeter 接口调用 greet,该方法在 AB 中都有定义
8 并且可以通过 Salute 接口调用 salute,该方法在 AB 中都有定义
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 中的等效代码需要将 o 强制转换为 Greeter 才能调用 greeting 方法,因为方法在编译时选择

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 因此,将 int 添加到 List<String> 是一个编译时错误

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

@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 因此,允许将 int 添加到 List<? extends Serializable>

引入流类型是为了减少经典 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 为 true,o 被赋值为 Top
2 如果 someCondition 为 false,o 被赋值为 Bottom
3 调用 methodFromTop 是安全的
4 但调用 methodFromBottom 不安全,因此是编译时错误

当类型检查器访问 if/else 控制结构时,它会检查 if/else 分支中所有被赋值的变量,并计算所有赋值的最小上界。此类型是 if/else 块之后推断变量的类型,因此在此示例中,oif 分支中被赋值为 Top,在 else 分支中被赋值为 Bottom。它们的LUBTop,因此在条件分支之后,编译器推断 oTop。因此,允许调用 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,因此在 B 实例上调用 computeFully 的人会看到运行时错误。编译器通过使用方法的声明返回类型而不是推断返回类型来防止这种情况发生。

为了一致性,此行为对**所有**方法都是相同的,即使它们是静态或最终方法。

参数类型推断

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

  • 通过隐式 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 类型系统,这不足以描述参数的类型。

@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) }

所有 FirstParam、SecondParam 和 ThirdParam 类型提示都存在 SecondGenericType 和 ThirdGenericType 的变体。

SimpleType

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

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 类型,并从映射的实际键/值类型推断闭包参数类型。

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 作为类型提示,它也并不严格意味着传递给闭包的参数**将是**方法调用的第一个(分别为第二个、第三个)参数。它只意味着闭包参数的**类型**将与方法调用的第一个(分别为第二个、第三个)参数的类型**相同**。

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

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

@DelegatesTo

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

6.3. 静态编译

6.3.1. 动态 vs 静态

类型检查部分中,我们看到 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 所做的一切,此外还触发静态编译。

让我们来看一下失败的示例,但这次我们将 @TypeChecked 注解替换为 @CompileStatic

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,处理抽象语法树。您需要非常了解 AST 才能开发扩展,即使 DSL 比直接处理纯 Java 或 Groovy 中的 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 并将 handled 标志设置为 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
    }
}

赋予开发人员处理不正确返回值的权限。例如,当返回值将进行隐式转换或闭包的 enclosing 目标类型难以正确推断时,这非常有用。在这种情况下,您只需告诉类型检查器赋值有效(通过设置 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。这两个类定义了许多辅助方法,这些方法将使处理 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
帮助类型检查器

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

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

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

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

抛出错误

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

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

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

isXXXExpression

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

if (node instanceof BinaryExpression) {
   ...
}

你可以直接写

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

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

  • 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),并判断该节点是否用此类注解。例如: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 将自动触发动态解析,因此在这种情况下,您无需做任何特殊操作即可让编译器使用混合模式。然而,让我们稍微更新一下我们的示例,从机器人脚本开始

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。类型检查**不应**修改 AST 树,因为您将无法再保证没有 @TypeChecked 注解的代码在没有注解的情况下行为相同。

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

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

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

7.2.8. 示例

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

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