Groovy 2.0 新特性之:静态类型检查

Groovy 2.0 刚刚发布,其中一项最大的改进就是支持静态类型检查。今天我们将对这个新特性进行全方位的介绍。

静态类型检查

Groovy 天生就是一个动态编程语言,它经常被当作是 Java 脚本语言,或者是“更好的 Java”。很多 Java 开发者经常将 Groovy 嵌入到 Java 程序中做为扩展语言来使用,更简单的描述业务规则,将来为不同的客户定制应用等等。对这样一个面向 Java 的用例,开发者不需要语言提供的所有动态特性,他们经常希望 Groovy 也提供一个类似 javac 的编译器,例如在发生一些错误的变量和方法名错误或者错误的类型赋值时就可以在编译时就知道错误,而不是运行时才报错。这就是为什么 Groovy 2.0 提供了静态类型检查功能的原因。

发现明显的错别字

静态类型检测器使用了 Groovy 已有强大的 AST (抽象语法树) 转换机制,如果你对这个机制不熟悉,你就把它当作一个可选的通过注解进行触发的编译器插件。这是一个可选的特性,可用可不用。要触发静态类型检查,只需要在方法上使用@TypeChecked 注解即可。让我们来看一个简单的例子:

import groovy.transform.TypeChecked

void someMethod() {}

@TypeChecked
void test() {
    // compilation error:
    // cannot find matching method sommeeMethod()
    sommeeMethod()

    def name = "oschina"

    // compilation error:
    // the variable naaammme is undeclared
    println naaammme
}

我们使用了 @TypeCheckedtest() 方法进行注解,这让 Groovy 编译器在编译期间运行静态类型检查来检查指定的方法。当我们试图用明显错误的方法来调用 someMethod() 时,编译器将会抛出两个编译错误信息表明方法和变量为定义

检查赋值和返回值

静态类型检查还能验证返回值和变量赋值是否匹配:

import groovy.transform.TypeChecked

@TypeChecked
Date test() {
    // compilation error:
    // cannot assign value of Date 
    // to variable of type int
    int object = new Date()

    String[] letters = ['o', 's', 'c']
    // compilation error:
    // cannot assign value of type String 
    // to variable of type Date
    Date aDateVariable = letters[0]

    // compilation error:
    // cannot return value of type String 
    // on method returning type Date
    return "today"
}

在这个例子中,编译器将告诉你不能将 Date 值赋值个 int 变量,你也不能返回一个 String,因为方法已经要求是返回 Date 类型数据。代码中间的编译错误信息也很有意思,不仅是说明了错误的赋值,还给出了类型推断,因为类型检测器知道  letters[0] 的类型是 String

类型推断  type inference

因为提到了类型推断,让我们来看看其他的一些情况,我们说过类型检测器会检查返回类型和值:

import groovy.transform.TypeChecked

@TypeChecked
int method() {
    if (true) {
        // compilation error:
        // cannot return value of type String
        // on method returning type int
        'String'
    } else {
        42
    }
}

指定了方法必须返回 int 类型值后,类型检查器将会检查各种条件判断分支的结构,包括 if/elese、try/catch、switch/case 等。在上面的例子中,如果 if 分支中返回字符串而不是 int,编译器就会报错。

自动类型转换

静态类型检查器并不会对 Groovy 支持的自动类型转换报告错误,例如对于返回  String, booleanClass 的方法,Groovy 会自动将返回值转成相应的类型:

import groovy.transform.TypeChecked

@TypeChecked
boolean booleanMethod() {
    "non empty strings are evaluated to true"
}

assert booleanMethod() == true

@TypeChecked
String stringMethod() {
    // StringBuilder converted to String calling toString()
    new StringBuilder() << "non empty string"
}

assert stringMethod() instanceof String

@TypeChecked
Class classMethod() {
    // the java.util.List class will be returned
    "java.util.List"
}

assert classMethod() == List

而且静态类型检查器在类型推断方面也足够聪明:

import groovy.transform.TypeChecked

@TypeChecked
void method() {
    def name = " oschina.net "

    // String type inferred (even inside GString)
    println "NAME = ${name.toUpperCase()}"

    // Groovy GDK method support
    // (GDK operator overloading too)
    println name.trim()

    int[] numbers = [1, 2, 3]
    // Element n is an int
    for (int n in numbers) {
        println 
    }
}

虽然变量 name 使用 def 进行定义,但类型检查器知道它的类型是 String. 因此当调用 ${name.toUpperCase()} 时,编译器知道在调用 String 的 toUpperCase() 方法和下面的 trim() 方法。当对 int 数组进行迭代时,它也能理解数组的元素类型是 int.

混合动态特性和静态类型的方法

你必须牢记于心是:静态类型检查限制了你可以在 Groovy 使用的方法。大部分运行时动态特性是不被允许的,因为他们无法在编译时进行类型检查。例如不允许在运行时通过类型的元数据类(metaclasses)来添加新方法。但当你需要使用一些例如 Groovy 的 builders 这样的动态特性时,如果你愿意,你还是可以选择静态类型检查。

@TypeChecked 注解可放在方法级别或者是类级别使用。如果你想对整个类进行类型检查,直接在类级别上放置这个注解即可,否则就在某些方法上进行注解。你也可以使用 @TypeChecked(TypeCheckingMode.SKIP) 或者是 @TypeChecked(SKIP) 来指定整个类进行类型检查除了某个方法。使用 @TypeChecked(SKIP) 必须静态引入对应的枚举类型。下面代码可以用来演示这个特性,其中 greeting() 方法是需要检查的,而 generateMarkup() 方法则不用:

import groovy.transform.TypeChecked
import groovy.xml.MarkupBuilder

// this method and its code are type checked
@TypeChecked
String greeting(String name) {
    generateMarkup(name.toUpperCase())
}

// this method isn't type checked
// and you can use dynamic features like the markup builder
String generateMarkup(String name) {
    def sw =new StringWriter()
    new MarkupBuilder(sw).html {
        body {
            div name
        }
    }
    sw.toString()
}

assert greeting("Cédric").contains("<div>CÉDRIC</div>")

类型推断和 instanceof 检查

目前的 Java 并不支持一般的类型推断,导致今天很多地方的代码往往是相当冗长,而且样板结构混乱。这掩盖了代码的实际用途,而且如果没有强大的 IDE 支持的话代码会很难写。于是就有了 instanceof 检查:你经常会在 if 条件判断语句中使用 instanceof 判断。而在 if 语句结束后,你还是必须手工对变量进行强行类型转换。而有了 Groovy 全新的类型检查模式,你可以完全避免这种情况出现:

import groovy.transform.TypeChecked
import groovy.xml.MarkupBuilder

@TypeChecked
String test(Object val) {
    if (val instanceof String) {
        // unlike Java: 
        // return ((String)val).toUpperCase()
        val.toUpperCase()
    } else if (val instanceof Number) {
        // unlike Java: 
        // return ((Number)val).intValue().multiply(2)
        val.intValue() * 2
    }
}

assert test('abc') == 'ABC'
assert test(123) == '246'

上述例子中,静态类型检查器知道 val 参数在 if 块中是 String 类型,而在 else if 块中是 Number 类型,无需再做任何手工类型转换。

最低上限 Lowest Upper Bound

静态类型检测器比一般理解的对象类型诊断要更深入一些,请看如下代码:

import groovy.transform.TypeChecked

// inferred return type:
// a list of numbers which are comparable and serializable
@TypeChecked test() {
    // an integer and a BigDecimal
    return [1234, 3.14]
}

在这个例子中,我们返回了数值列表,包括 IntegerBigDecimal. 但静态类型检查器计算了一个最低的上限,实际上是一组可序列化(Serializable)和可比较(Comparable)的数值。而 Java 是不可能表示这种类型的,但如果我们使用一些交集运算,那看起来就应该是 List<Number & Serializable & Comparable>.

不同对象类型的变量 Flow typing

虽然这可能不是一个好的方法,但有时候开发者会使用一些无类型的变量来存储不同类型的值,例如:

import groovy.transform.TypeChecked

@TypeChecked test() {
    def var = 123             // inferred type is int
    var = "123"               // assign var with a String

    println var.toInteger()   // no problem, no need to cast

    var = 123
    println var.toUpperCase() // error, var is int!
}

还有另外一些关于 “flow typing” 算法的特殊情况,当某个变量在一个闭包中被共享该会是怎么样的一种情况呢?

import groovy.transform.TypeChecked

@TypeChecked test() {
    def var = "abc"
    def cl = {
        if (new Random().nextBoolean()) var = new Date()
    }
    cl()
    var.toUpperCase() // compilation error!
}

var 本地变量先赋值了一个字符串,但是在闭包中会在一些随机的情况下被赋值为日期类型数值。一般情况下这种只能在运行时才能报错,因为这种错误是随机发生的。因此在编译时,编译器是没有机会知道 var 变量是字符串还是日期,这就是为什么编译器无法得知错误的原因。尽管这个例子有点做作,但还有更有趣的情况:

import groovy.transform.TypeChecked

class A           { void foo() {} }
class B extends A { void bar() {} }

@TypeChecked test() {
    def var = new A()
    def cl = { var = new B() }
    cl()
    // var is at least an instance of A
    // so we are allowed to call method foo()
    var.foo()
}

test() 方法中,var 先被赋值为 A 的实例,紧接着在闭包中被赋值为 B 的实例,然后调用这个闭包方法,因此我们至少可以诊断 var 最后的类型是 A。

Groovy 编译器的所有这些检查都是在编译时就完成了,但生成的字节码还是跟一些动态代码一样,在行为上没有任何改变。

静态编译

正如我们将在下面看到的JDK 7 相关阵营,Groovy 2.0 支持JVM新的“动态调用”用法和新的相关API,促进Java平台的动态语言发展并且带来了一些 Groovy 的动态调用性能上的提升。然而不幸地的是,在笔者写这篇文章的时候JDK 7 还没有广泛地用在产品当中,所以并不是每个人都有机会运行最新的一个版本。所以那些没机会运行 JDK 7 的开发者们正在寻找一种不像 Groovy 2.0 有这么多变化的性能提升方法。幸好,Groovy 开发团队考虑到那些开发者对性能提升感兴趣,除去其它优势,它可以允许代码类型检查来达到静态编译。

事不宜迟,让我们看看全新的 @CompileStatic 转换

import groovy.transform.CompileStatic

@CompileStatic
int squarePlusOne(int num) {
    num * num + 1
}

assert squarePlusOne(3) == 10
这一次我们使用@CompileStatic替代@TypeChecked,代码将会被静态编译。生成的字节码将会和javac生成的字节码很相似,一样可以快速运行。跟@TypeChecked注释相似,@CompileStatic可以用于类和方法,并且@CompileStatic(SKIP)可以通过指定的方法跳过静态编译,即使该类被标记为@CompileStatic


另外一个优点就是,类似javac字节码,通过注解声明的方法生成的字节码,跟一般的Groovy动态编程生成的字节码相比,字节码会更小。因为,为了Groovy动态编程特性,字节码里面会包含一些指令供Groovy运行系统调用。

最后,但也同样重要的是,静态编译可以被框架或者库利用,去避免由于动态编程被系统里面的多个地方调用而引起的不良互相作用。动态编程特性在例如Groovy等语言中得到支持,可以给开发人员更强大的力量和可伸缩性,但是如果使用的不当,不同的假设场景会出现,并且会带来意想不到的结果。我们以一个稍微夸张的例子来说明,想象一下,当你在一个核心的类里面使用两个不同的库,并且两个库都有一个相似的方法名字,但是实现方法不一样的时候。我们想得到什么样的结果?有经验的动态编程人员应该会曾经碰到过,并且这个会被叫做“monkey patching”。支持静态编程 - 那一部分不需要使用的动态编程特性的代码 - 会保护你免受monkey patching,因为静态编译后的代码不会进入Groovy动态运行环境中。虽然动态运行是不允许在静态编译上下文中存在,一般的AST转换机制照样正常运行,因为大部分的AST转换都在编译阶段发挥作用。

在性能方面,Groovy的静态编译代码和javac生成的代码一样拥有快速的执行性能。开发团队使用的某些微基准测试中,性能可以通过某些场合体现,并且某些时候会轻微有点慢。

从历史来看,归功于Java和Groovy的透明和无缝集成,我们以前会建议Java开发人员对热点单元做优化,以得到更好的性能,但现在,有了静态编译的选择,我们不再需要了,那些希望通过Groovy完整的开发的项目就可做到。

Java 7和 JDK 7主题

Groovy的语法实际是继承于Java的语法,但很明显,Groovy提供更好的捷径让开发人员更高效。那些对熟悉Java的开发人员更友好的语法结构,经常被用来推销项目,并且得到推广,这要归功于扁平的学习曲线。当然,我们希望Groovy用户和新手同样可以得益于Java 7(增加了Project Coin)的一些语法改进成果。

除了语法方面,JDK 7 同样给我们带来了新奇的APIs,第一次在很长时间里面,甚至新的字节码指令“invoke dynamic”,被用来帮助更容易实现动态编程,并且得到更多的性能优化。

Project Coin语法扩展

因为第一天(那已经是2003年的事情了)Groovy在java的基础上更新了一些语法和特性。其中例如闭包,可以支持在switch/case语法中增加更多离散数值,而Java只允许字符串。所以一些Project coin语法扩展,例如switch里面使用String,已经在Groovy中得到支持。但是,一些扩展却是全新的,例如binary literals,数字下划线,或者多个catch块,在Groovy 2都可以得到支持。Project coin扩展唯一的遗漏就是"try with resource"结构,Groovy已经通过丰富的API提供各种代替方法。

二进制文字

在Java 6和之前的版本以及Groovy中,数字可以十进制,八进制和十六进制表示。而在Java 7和Groovy 2里,你可以使用二进制表示,只要用“0b”前缀。

int x = 0b10101111
assert x == 175

byte aByte = 0b00100001
assert aByte == 33

int anInt = 0b1010000101000101
assert anInt == 41285

数字常量中的下划线

当写很长的数字常量时,很难指出数字是怎么分组的。例如把每三个数分成一组写在一起等等。通过允许在数字常量中添加下划线就能解决这个问题。

long creditCardNumber = 1234_5678_9012_3456L
long socialSecurityNumbers = 999_99_9999L
double monetaryAmount = 12_345_132.12
long hexBytes = 0xFF_EC_DE_5E
long hexWords = 0xFFEC_DE5E
long maxLong = 0x7fff_ffff_ffff_ffffL
long alsoMaxLong = 9_223_372_036_854_775_807L
long bytes = 0b11010010_01101001_10010100_10010010

支持多个异常的捕捉块

当异常被捕捉,我们通常会重复的复制异常捕捉块,因为我们想使用同样的方法处理两个或者以上的异常。如果不像这么做,要不就通过分解它们的共性,或者以更丑的方式去实现一个捕捉块捕捉所有异常的,或者直接写成throwable。有了多异常捕捉块,我们可以只写一个捕捉块就可以处理各种需要捕捉的异常。

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

动态调用支持

正如我们在这个主题中早期提出的,JDK 7具有一项新的字节码指令特性,称为“动态调用”,并有相关的API接口实现。其目标是帮助在java平台基础上实现动态语言特性,通过简单编写动态方法调用,实现定义"call sites",其是一个可动态方法调用部分,“method handles” 作为指针,“class values”可存储各种元数据作为类的对象,还包括其他一些东西。需说明的是,尽管官方保证此特性在性能上有提升,但是至今还未完全在JVM中得到全面的优化使用,同时也未到达最好的性能,但是,我们相信在不断的升级后,我们会看到优化的效果的。

Groovy带来了其特有的技术实现,以”调用区域缓存“方式提高方法调用的速度,通过存储运行中的原数据类(动态运行的类)并对其进行注册,以提升本地原数据的计算性能,能够达到Java运行速度,甚至超过它。而且随着”动态调用“的出现,我们可以以这个实现为基础,通过上层的API和JVM字节码指令,获得性能的提升和简化代码实现。如果你能够有幸在JDK 7上运行Groovy,你将能够使用到其包含了新版本支持“动态调用”的JAR文件。那些JAR文件可通过名称带"-indy"标识很容易的辨识出。

开启动态调用支持

但是使用"indy" JARs还不足够,编译你的Groovy代码好让它充分支持"invoke dynamic"。为了达到这个目的,你需要在使用"groovyc"或者"groovy"编译的时候 加上 --indy标记。这还意味着,即使你使用indy jar,你仍然能在JDK 5或者6上编译。

类似的,如果使用groovyc Ant任务去编译工程,同样需要指定indy属性:

...
<taskdef name="groovyc"
        classname="org.codehaus.groovy.ant.Groovyc"
        classpathref="cp"/>
...
<groovyc srcdir="${srcDir}" destdir="${destDir}" indy="true">
    <classpath>
...
    </classpath>
</groovyc>
...

Groovy Eclipse Maven编译插件还未能支持Groovy 2.0,但是这不会要你等待很久。对于GMaven插件用户,虽然是可以通过配置去支持Groovy 2.0, 但是现在还没有任何配置选项去支持动态调用。同样的,GMaven将会很快解决这个问题。

在Java应用中通过Groovy shell集成Groovy,例如,你可以通过Groovy shell构造器向CompilerConfigurationinstance 传参,以支持动态调用。

CompilerConfiguration config = new CompilerConfiguration();
config.getOptimizationOptions().put("indy", true);
config.getOptimizationOptions().put("int", false);
GroovyShell shell = new GroovyShell(config);
动态调用被认为是一种对动态方法调用的完全代替,这里需要禁用原始的优化,而不生成多余的字节码,这样就可以优化边缘情况。即使有些时候,运行速度会比原始优化开启的时候要慢,但是之后的JVM版本将会改进JIT,支持让大部分调用支持内联,并且减少不必要的加箱操作。

性能优化

在我们的测试中,我们发现某些场合中,一些有趣的性能增加,而一些程序会比没有使用动态调用的程序运行的更慢。Groovy开发组在Groovy2.1的管道性能优化上,还有很长的路要走。我们发现JVM还未得到最好的优化,并且达到最佳性能优化的路上,还有很长的路要走。庆幸,将来的JDK 7更新(尤其是 update 8)已经包含这样的优化,这样的场合只能是改善。还有,因为动态调用将会在JDK8的匿名表达式中使用,所以我们相信性能将会逐渐优化。

更加模块化的Groovy

我们讨论完模块化之后,即将完成Groovy 2.0新特性的旅程。就像Java一样,Groovy不只是一种语言,它还是一组为了解决各种问题的APIs:模板,构建Swing UI,Ant脚本,JMX集成,SQL访问,sevelet,还有其他。Groovy通过一个单独的JAR提供这些特性和API。但是,不是所有人都需要所有这些特性:你在构建web应用的时候,或许对模板引擎和servlets感兴趣,但是你在开发一个富桌面应用的时候,可能只需要Swing构造器。

Groovy模块

所以模块化的首要目标就是把这个发布版本的Jar切分成小的模块,小型JARs。核心的Groovy JAR是以前的两倍大,以下的模块可供使用:

通过Groovy2,你现在可以选择你感兴趣的模块,而不是把所有模块都包含在你的classpath里面。但是,我们仍然提供一个包含所有模块的JAR,如果你不需要处理复杂的依赖关系,这只需占用你小小几兆空间就可以了。我们同样向在运行于JDK 7的应用,提供包含通过"invoke dynamic"支持的编译JARs,

扩展模块

在让Groovy更模块化过程中,带出了一个更有趣的特性:扩展模块。通过把Groovy切分成一个个小的模块,这给我们带来一个机制去构建扩展模块。通过这个方法,扩展模块可以向其他类提供实力和静态方法,包括JDK的和第三方库。Groovy使用这种机制,装饰JDK中的类,例如为String,file,stream添加新的方法 - 例如在URL中getText()方法,允许你在调用HTTP get的时候获取远程服务器的内容。需要注意,模块中的扩展方法同样可以被静态类型检查器和编译器所理解。现在让我们来看看如何向现有的类型添加新的方法。

创建实例方法

为了向现有的类型添加新的方法,需要创建一个helper类,并包含所有这些新的方法。在helper类里面,所有的扩展方法实际上是公共的(在Groovy里面默认是公共的,但在Java里面必须声明)和静态的(虽然这些方法在类的实例中可用)。这些方法,第一参数一般是调用这个方法的实际实例。后面的参数就是调用这个方法所必需的参数。这跟Groovy categories的约束一样。

也就是说,我们需要想Sting类添加agreets()方法,这个方法会向传递给参数的那个人提示欢迎信息,那么你可以这样使用这个方法:

assert "Guillaume".greets("Paul") == "Hi Paul, I'm Guillaume"
为了实现这个方法,你需要创建一个helper类,并包含扩展方法:


package com.acme

class MyExtension {
    static String greets(String self, String name) {
        "Hi ${name}, I'm ${self}"
    }
}

贡献扩展的静态方法

方法的静态扩展机制和规则没有变化。比如我们为Random添加一个静态方法,来随机获取两个数之间的某个值可以在类中如下编码处理

package com.acme

class MyStaticExtension {
    static String between(Random selfType, int start, int end) {
        new Random().nextInt(end - start + 1) + start
    }
}

这样你就可以使用那个扩展方法了:

Random.between(3, 4)

扩展模块的描述器

当完成包含扩展方法的helper类(用Groovy或java)的代码编写之后要给这个扩展模块新建一个描述器。你必须在这个扩展模块包的META_INF/service目录下创建名为org.codehaus.groovy.runtime.ExtensionModule的模块描述文件.须定义四个主要字段来告诉Groovy 运行时该模块的名称,版本,指向扩展了类和静态扩展类的helper类名列表(逗号分隔) .示范如下:

moduleName = MyExtension
moduleVersion = 1.0
extensionClasses = com.acme.MyExtension
staticExtensionClasses = com.acme.MyStaticExtension

有了这个扩展模块描述文件在 classpath中,你就可以使用你代码中扩展好的方法了,不必再import或其他动作了,因为这些扩展方法已经自动注册上了。

抓取扩展模块

在脚本中通过@Grab注释,可以在Maven repo,例如Maven central中获取依赖关系。通过添加@GrabResolver注释,就可以指定你的依赖所在的位置。如果你正通过这个机制,在抓取扩展模块的依赖关系,那么扩展发发也会自动加载。理想情况下,为了一致性,你的模块名字和版本必须和artifact id和版本一致。

总结

Groovy在Java开发者中非常流行,它提供了一个成熟的平台和生态系统,可以满足Java应用程序开发中的各种需求。但是,Groovy开发小组并没有原地踏步,一直都在不断地对Groovy语言及其API进行着各种改进,帮助其使用者提高他们在Java平台之上的生产力。

Groovy 2.0主要在以下三个大的方面进行了改进: