本章介绍 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 代码向后兼容;因此您可以将共享相同代码的 case 穿透多个匹配项。

不过,一个区别是 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 真值 为真,则闭包 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 1.6 以来,Groovy 一直支持多重赋值语句

// 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

Groovy 通常为 Java 7 的自动资源管理 (ARM) try-with-resources 语句提供更好的替代方案。该语法现在已支持从 Java 迁移到 Groovy 的 Java 程序员,他们仍然想要使用旧的风格

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. 强大断言

与 Groovy 共享 assert 关键字的 Java 不同,Groovy 中的 assert 行为非常不同。首先,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

当表达式更复杂时,例如在下一个示例中,Power 断言变得非常有趣

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

类字面量

( expression )

带括号的表达式

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]

字面量映射表达式

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"] → 类似映射的表示法:所有 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()

但是,as Type 表达式是可选的,因为 Groovy 2.2.0。您可以省略它,只需编写

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()

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

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

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

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

3.3. 映射到类型的强制转换

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

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

当然,这是一个相当牵强的例子,但说明了这个概念。您只需要实现实际调用的那些方法,但如果调用了映射中不存在的方法,则会抛出 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. 映射

非空映射被评估为真。

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

5.6. 字符串

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

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.Closure 并且 A 是一个 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. 列表和映射构造函数

除了上面的赋值规则,如果赋值被认为是无效的,在类型检查模式下,一个 *列表* 字面量或一个 *映射* 字面量 A 可以被赋值给类型为 T 的变量,如果

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

  • 赋值是一个变量声明,并且 A 是一个映射字面量,且 T 有一个无参构造函数,以及一个对应于映射键的每个属性

例如,而不是写

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

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

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

或“映射构造函数”

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

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

@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
  • 或者 TString,并且 AGString

    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.Closure 并且 A 是一个 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 的字段和非 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 只有一个 (1) 公共接口,并且它们的公共超类是 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 的最小上界中定义的。

实例化推断

在正常的,未经类型检查的 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(从而激活类型检查),强制转换也不必要。编译器嵌入实例化推断,使得强制转换可选。

流类型

流类型是 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 编译器推断出 o 是一个 String,因此允许调用 toUpperCase
3 o 重新分配了一个 double
4 调用 Math.sqrt 通过了编译,因为编译器知道此时 o 是一个 double

因此,类型检查器知道变量的具体类型随着时间的推移而不同。特别是,如果你用以下内容替换最后一个赋值

o = 9d
o = o.toUpperCase()

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

重要的是要理解,并不是用 def 声明一个变量才会触发类型推断。流类型适用于任何类型的任何变量。用显式类型声明变量只会限制你对变量的赋值

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List list = ['a','b','c']           (1)
    list = list*.toUpperCase()          (2)
    list = 'foo'                        (3)
}
1 list 被声明为未经检查的 List 并分配了一个 String 的列表文字
2 这行代码通过了编译,因为流类型:类型检查器知道此时 list 是一个 List<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,因为选择在运行时的方法是基于实际的参数类型。因此,在运行时,o 是一个 String,所以使用 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。这些的 LUB 是一个 Top,因此在条件分支之后,编译器推断出 o 是一个 Top。因此,允许调用 methodFromTop,但不允许调用 methodFromBottom

相同的推理也适用于闭包,特别是闭包共享变量。闭包共享变量是指在闭包外部定义,但在闭包内部使用的变量,例如在这个例子中

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

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

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()

如你所见,与显式声明其返回类型的函数不同,闭包不需要声明其返回类型:它的类型是从闭包的主体推断出来的。

闭包与函数

值得注意的是,返回类型推断只适用于闭包。虽然类型检查器可以对函数做同样的事情,但在实践中这是不可取的:一般而言,函数可以被覆盖,而且不可能在静态地确保被调用的函数不是被覆盖的版本。因此,流类型实际上会认为函数返回了一些东西,而实际上,它可能会返回其他东西,就像在下面的例子中所示

@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 类型系统,而 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

第二个可选参数名为选项。它的语义取决于类型提示类。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
}

此类型提示支持单个签名,并且每个参数都使用完全限定的类型名称或基本类型作为选项数组的值指定。

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 }

多态闭包,接受 TT,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 源代码中),则类型检查器拥有更多信息,并且可以执行额外的类型推断。这使得此功能对于框架开发人员来说尤为有趣。

第三个可选参数名为冲突解决策略。它可以引用一个类(扩展自 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,处理抽象语法树。您必须熟悉 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

调用时间

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

参数

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 看起来更美观,更易于编写。但是,对事件做出反应远远不够。如果您知道可以对事件做出反应,您还需要处理错误,这意味着需要一些辅助方法,这将使事情变得更容易。

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 方法,该方法接受两个参数

  • 一个 message,它是一个字符串,将显示给最终用户

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

isXXXExpression

通常需要知道 AST 节点的类型。为了可读性,DSL 提供了一个特殊的 isXXXExpression 方法,该方法将委托给 x instance of 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 方法后才激活,在此范围之外则不激活。但是,你可能会遇到一些复杂的情况,例如同一个文件中存在多个构建器,或者存在嵌套构建器(构建器中的构建器)。虽然你不应该试图从一开始就解决所有这些问题(你必须接受类型检查的局限性),但类型检查器确实提供了一种很好的机制来处理这个问题:一个范围栈,使用 newScope 和 scopeExit 方法。

  • 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
}

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

newScope {
    secondPassChecks = []
}

在 DSL 中的任何时候,你都可以使用 getCurrentScope() 或更简单的 currentScope 来访问当前范围

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

一般的方案如下:

  • 确定一个 切入点,在这个 切入点 上,你将一个新范围推入栈中,并在该范围内初始化自定义变量

  • 使用各种事件,你可以使用存储在你的自定义范围中的信息来执行检查、延迟检查…

  • 确定一个 切入点,在这个 切入点 上,你将退出范围,调用 scopeExit,并最终执行额外的检查

其他有用方法

有关辅助方法的完整列表,请参阅 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport 和 org.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 编写类型检查扩展是最简单的路径。基本上,这个想法是类型检查扩展脚本成为类型检查扩展类的 main 方法的主体,如下所示

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 文件中,该 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调用做了三件事

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

  • 自动将handled标志设置为true

  • 但也将call标记为动态进行

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

有些人可能想知道为什么静态编译器默认情况下不会在没有扩展的情况下这样做。这是一个设计决策

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

  • 因此,如果无法识别的变量/方法调用被设置为动态,那么你会失去类型安全,还会失去编译时对拼写错误的所有支持!

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

makeDynamic可以在三种 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 模板引擎源代码中可以找到一个复杂类型检查扩展的示例:这个模板引擎依赖于类型检查扩展和 AST 转换,将模板转换为完全静态编译的代码。这些源代码可以在此处找到。