1. 介绍
本篇是Typing相关知识的最后一篇。介绍关于类型的闭包和类型推断关系,以及最终的类型静态编译相关知识点。
2. 闭包和类型推断
类型检查器对闭包执行特殊的推断,在一边执行额外的检查,在另一边提高流畅性。
2.1 返回类型推断
类型检查器能够做的第一件事是推断闭包的返回类型。下面的例子简单地说明了这一点:
代码语言:javascript复制@groovy.transform.TypeChecked
int testClosureReturnTypeInference(String arg) {
def cl = { "Arg: $arg" } //定义一个闭包,它返回一个GString字符串
def val = cl() //调用闭包并将结果赋值给一个变量
val.length() //类型检查器推断闭包将返回一个字符串,因此允许调用length()
}
正如上面所看到的,与显式声明其返回类型的方法不同,不需要声明闭包的返回类型:它的类型是从闭包的主体推断出来的。
2.2 闭包vs方法
返回类型推断仅适用于闭包。虽然类型检查器可以对方法执行相同的操作,但实际上并不可取:通常情况下,方法可以被覆盖,并且静态地不可能确保所调用的方法不是被覆盖的版本。所以流类型实际上会认为一个方法返回一些东西,而在现实中,它可以返回其他东西,如下面的例子所示:
代码语言:javascript复制@TypeChecked
class A {
def compute() { 'some string' } //类A定义了一个方法compute,它返回一个字符串
def computeFully() {
compute().toUpperCase() //这将导致编译失败,因为compute的返回类型是def(又名Object)
}
}
@TypeChecked
class B extends A {
def compute() { 123 } //类B扩展了A并重新定义了compute,该类型返回一个整型
}
通过上面的示例可以知道,如果类型检查器依赖于方法的推断返回类型(使用流类型),则类型检查器可以确定是否可以调用toUpperCase
。这实际上是一个错误,因为子类可以重写compute
并返回不同的对象。这里,B.compute
返回一个整型,因此在B
的实例上调用computeFully
将会看到一个运行时错误。编译器通过使用方法的声明返回类型而不是推断返回类型来防止这种情况发生。
为了保持一致性,这种行为对于每个方法都是相同的,即使它们是静态的或最终的。
2.3 参数类型推断
除了返回类型外,闭包还可以从上下文推断其参数类型。编译器有两种方法来推断形参类型:
- 通过隐式SAM类型强制
- 通过API元数据
让我们从一个由于类型检查器无法推断形参类型而导致编译失败的示例开始:
代码语言:javascript复制class Person {
String name
int age
}
void inviteIf(Person p, Closure<Boolean> predicate) { //inviteIf方法接受一个Person和一个闭包
if (predicate.call(p)) {
// send invite
// ...
}
}
@groovy.transform.TypeChecked
void failCompilation() {
Person p = new Person(name: 'Gerard', age: 55)
inviteIf(p) {
it.age >= 18 // No such property: age 它并不是静态地称为Person,因此编译失败
}
}
在这个例子中,闭包体包含了it.age
。对于动态的、非类型检查的代码,这是可行的,因为它的类型在运行时是Person
。不幸的是,在编译时,没有办法知道它的类型,只能通过读取inviteIf
的签名。
2.3.1 显式闭包参数
简而言之,类型检查器在inviteIf
方法上没有足够的上下文信息来静态确定it
的类型。这意味着方法调用需要像这样重写:
inviteIf(p) { Person it -> //它的类型需要显式地声明
it.age >= 18
}
通过显式声明it
变量的类型,可以解决这个问题,并使此代码进行静态检查。
2.3.2 从单一抽象方法类型推断出的参数
对于API或框架设计人员来说,有两种方法可以使其对用户来说更优雅,这样他们就不必为闭包参数声明显式类型。第一个方法,也是最简单的方法,是用SAM类型替换闭包:
代码语言:javascript复制interface Predicate<On> { boolean apply(On e) } //使用apply方法声明SAM接口
void inviteIf(Person p, Predicate<Person> predicate) {
if (predicate.apply(p)) {
// send invite
// ...
}
}
@groovy.transform.TypeChecked
void passesCompilation() {
Person p = new Person(name: 'Gerard', age: 55)
inviteIf(p) { //不再需要声明it变量的类型了
it.age >= 18 //it.age正确编译后,它的类型是从Predicate#apply方法签名推断出来的
}
}
通过使用这种技术,我们利用了Groovy将闭包自动强制转换为SAM类型的特性。
我们应该使用SAM类型还是Closure的问题实际上取决于需要做什么。
在很多情况下,使用SAM接口就足够了,特别是当考虑Java 8中的功能接口时。
但是,闭包提供了功能接口无法访问的特性。特别是,闭包可以有一个委托和所有者,并且可以在被调用之前作为对象进行操作(例如,克隆、序列化、curry等等)。它们还可以支持多个签名(多态性)。
因此,如果需要这种操作,最好切换到下面描述的最高级的类型推断注释。
当涉及到闭包参数类型推断时,最初需要解决的问题是,Groovy类型系统继承了Java类型系统,而Java类型系统不足以描述参数的类型,也就是说,静态地确定闭包的参数类型,而无需显式地声明它们。
2.3.3 使用@ClosureParams
注解
Groovy提供了一个注解@ClosureParams
,用于完成类型信息。该注释主要针对那些希望通过提供类型推断元数据来扩展类型检查器功能的框架和API开发人员。如果我们的库使用闭包,并且也希望获得最大级别的工具支持,那么这一点非常重要。
让我们通过修改原始示例来说明这一点,引入@ClosureParams
注释:
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FirstParam
void inviteIf(Person p, @ClosureParams(FirstParam) Closure<Boolean> predicate) { //闭包参数用@ClosureParams注释
if (predicate.call(p)) {
// send invite
// ...
}
}
inviteIf(p) {
it.age >= 18 //没有必要为它使用显式类型,因为它是推断出来的
}
@ClosureParams
注释最少接受一个参数,该参数被命名为类型提示。类型提示是一个类,它负责在闭包的编译时完成类型信息。在本例中,使用的类型提示是groovy.transform.stc.FirstParam
,它向类型检查器指示闭包将接受一个类型为方法第一个参数类型的参数。在本例中,方法的第一个参数是Person
,因此它向类型检查器指示闭包的第一个参数实际上是Person
。
第二个可选参数名为options
。它的语义取决于类型提示类。Groovy提供了各种捆绑的类型提示,如下表所示:
类型提示 | 多态 | 描述和示例 |
---|---|---|
FirstParam SecondParam ThirdParam | No | 第一个(回复。第二,第三)参数类型的方法: 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 | No | 第一个泛型类型(resp。第二,方法的第三)参数 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) }Variants for SecondGenericType and ThirdGenericType exist for all FirstParam, SecondParam and ThirdParam type hints. |
SimpleType | No | 闭包参数的类型来自选项字符串的类型提示。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 | Yes | 一个专用的闭包类型提示,可以在Map.Entry的单个参数,或者两个参数分别对应键和值。import groovy.transform.stc.MapEntryOrKeyValue public <K,V> void doSomething(Map<K,V> map, @ClosureParams(MapEntryOrKeyValue) Closure c) { // ... } doSomething([a: 'A']) { k,v -> assert k.toUpperCase() == v.toUpperCase() } doSomething([abc: 3]) { e -> assert e.key.length() == e.value }这个类型提示要求第一个参数是Map类型,并从Map实际的键/值类型推断闭包参数类型。 |
FromAbstractTypeMethods | Yes | 从某种类型的抽象方法推断闭包参数类型。为每个抽象方法推断一个签名。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 | Yes | 从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 }一个多态闭包,接受String或String, 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() } |
即使你使用FirstParam
, SecondParam
或ThirdParam
作为类型提示,这并不严格意味着将传递给闭包的参数将是第一个(resp。方法调用的第二个,第三个)参数。这只意味着闭包的参数类型将与第一个(resp。方法调用的第二个,第三个)参数。
PS: 上面的表格,从Groovy中直接赋值的。所以表格阅读比较难看
简而言之,在接受Closure
的方法上缺少@ClosureParams
注释不会导致编译失败。如果存在(它可以出现在Java源代码中,也可以出现在Groovy源代码中),则类型检查器具有更多信息,并可以执行额外的类型推断。这使得框架开发人员对该特性特别感兴趣。
第三个可选参数名为conflictResolutionStrategy
。它可以引用一个类(从
ClosureSignatureConflictResolver
扩展而来),如果在初始推断计算完成后发现了多个参数类型,则该类可以执行额外的参数类型解析。Groovy提供了一个默认类型解析器,它什么都不做,另一个则在找到多个签名时选择第一个签名。解析器仅在发现多个签名时调用,并且被设计为后处理器。任何需要注入类型信息的语句都必须传递一个通过类型提示确定的参数签名。解析器然后从返回的候选签名中选择。
类型检查器使用@DelegatesTo
注释推断委托的类型。它允许API设计者指示编译器委托的类型和委托策略。@DelegatesTo
注释将在其他内容中进行专门的讨论。这里就不扩展了。
3. 静态编译
3.1 动态与静态
在类型检查部分,我们已经看到Groovy通过@TypeChecked
注释提供了可选的类型检查。类型检查器在编译时运行,并对动态代码执行静态分析。无论是否启用类型检查,程序的行为都完全相同。这意味着@TypeChecked
注释对于程序的语义是中立的。尽管可能有必要在源中添加类型信息以使程序被认为是类型安全的,但最终,程序的语义是相同的。
虽然这听起来很好,但实际上有一个问题:在编译时执行的动态代码的类型检查,根据定义,只有在没有发生特定于运行时的行为时才正确。例如,下面的程序通过了类型检查:
代码语言:javascript复制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'
}
}
有两种计算方法。一个接受String
并返回int
,另一个接受int
并返回String
。如果你编译这个,它被认为是类型安全的:内部compute('foobar')
调用将返回一个int
,并且在这个int
上调用compute
将返回一个String
。
现在,在调用test()
之前,考虑添加以下行:
Computer.metaClass.compute = { String str -> new Date() }
使用运行时编程,我们实际上是在修改compute(String)
方法的行为,这样它就不会返回所提供的参数的长度,而是返回一个Date
。如果执行该程序,它将在运行时失败。因为这一行可以在任何线程的任何地方添加,所以类型检查器绝对没有办法静态地确保没有这样的事情发生。简而言之,类型检查器很容易受到猴子修补的攻击。这只是一个例子,但它说明了对动态程序进行静态分析本质上是错误的。
Groovy为@typecheck
提供了另一种注释,它实际上将确保被推断为被调用的方法将在运行时有效地被调用。该注释将Groovy编译器转换为静态编译器,其中所有方法调用都在编译时解析,生成的字节码确保实现这一点:注释是@groovy.transform.CompileStatic
。
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
方法不再受猴子补丁的影响,因为在它的主体中调用的计算方法在编译时是链接的,所以即使Computer
的元类发生了变化,程序仍然按照类型检查器的预期行事。
3.3 关键优势
在代码中使用@CompileStatic
有几个好处:
- 类型安全
- 对猴子补丁(monkey patching)免疫
- 性能改进
性能的提高取决于所执行程序的类型。
如果它受I/O限制,静态编译代码和动态代码之间的区别几乎不明显。
对于高度CPU密集型的代码,由于生成的字节码与Java为等效程序生成的字节码非常接近(如果不是相等的话),因此性能得到了极大的提高。
4. 小结
到这里关于类型的相关知识就介绍完毕了,以上内容可以通过Groovy官方文档:Groovy Language Documentation (groovy-lang.org)了解更多知识。
PS:类型知识的介绍更多的是从各种概念定义等方面详细介绍各种类型推断的过程。我们其实可以简单了解。在使用过程中来一点点理解