接着上篇介绍的类型相关的知识内容,继续了解Groovy中关于类型Typing的相关知识内容。
上一篇内容分享了关于静态类型检测的部分知识要点。34. Groovy 语法 类型知识详解-第一篇
本章继续。
类型推断的原则:当代码被@typecheck
注释时,编译器执行类型推断。它不仅仅依赖于静态类型,而且还使用各种技术来推断变量的类型、返回类型、字面量等等,这样即使激活了类型检查器,代码也尽可能保持干净。
下面通过简单的示例来了解类型推断:
def message = 'Welcome to Groovy!' //变量使用def关键字声明
println message.toUpperCase()
println message.upper() // compile time error
toUpperCase
调用能够工作的原因是消息类型被推断为String
。
值得注意的是,尽管编译器对局部变量执行类型推断,但它不会对字段执行任何类型的类型推断,总是返回到字段的声明类型。为了说明这一点,让我们来看看这个例子:
class SomeClass {
def someUntypedField
String someTypedField
void someMethod() {
someUntypedField = '123'
someUntypedField = someUntypedField.toUpperCase() // compile-time error
}
void someSafeMethod() {
someTypedField = '123'
someTypedField = someTypedField.toUpperCase()
}
void someMethodUsingLocalVariable() {
def localVariable = '123'
someUntypedField = localVariable.toUpperCase()
}
}
为什么会有这样的差异? 原因是线程安全。
在编译时,我们不能保证字段的类型。任何线程都可以在任何时间访问任何字段,并且在方法中为字段分配某种类型的变量和之后使用的时间之间,另一个线程可能已经更改了字段的内容。
对于局部变量则不是这样:我们知道它们是否“转义”,因此我们可以确保变量的类型随着时间的推移是常量(或非常量)。
请注意,即使字段是final
的,JVM也不会保证它,因此无论字段是否是final
的,类型检查器的行为都不会有所不同。
这是Groovy建议使用类型化字段的原因之一。虽然由于类型推断,对于局部变量使用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> |
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> |
def intRange = (0..10) | groovy.lang.IntRange |
def charRange = ('a'..'z') | groovy.lang.Range<String> 使用边界的类型来推断范围的组件类型 |
正如我们所看到的,除了IntRange
之外,推断类型使用泛型类型来描述集合的内容。如果集合包含不同类型的元素,类型检查器仍然执行组件的类型推断,但使用最小上界的概念。
在Groovy中,两种类型A
和B
的最小上界定义为:
A
和B
的公共超类A
和B
实现的接口A
或B
是基本类型,且A
不等于B
,则A
和B
的最小上界是它们包装器类型的最小上界如果A
和B
只有一个公共接口,并且它们的公共超类是Object,那么两者的LUB
(最小上界)就是公共接口。
最小上界表示A
和B
都能赋值的最小类型。例如,如果A
和B
都是String
,那么两者的LUB
(最小上界)也是String
。
class Top {}
class Bottom1 extends Top {}
class Bottom2 extends Top {}
assert leastUpperBound(String, String) == String
assert leastUpperBound(ArrayList, LinkedList) == AbstractList
assert leastUpperBound(ArrayList, List) == List
assert leastUpperBound(List, List) == List
assert leastUpperBound(Bottom1, Bottom2) == Top
assert leastUpperBound(List, Serializable) == Object
在这些示例中,LUB总是可以表示为JVM支持的普通类型。但是Groovy在内部将LUB表示为一种更复杂的类型。
例如,不能使用它来定义变量:
interface Foo {}
class Top {}
class Bottom extends Top implements Serializable, Foo {}
class SerializableFooImpl implements Serializable, Foo {}
Bottom
和SerializableFooImpl
的最小上界是多少?
它们没有共同的超类(除了Object),但是它们共享两个接口(Serializable
和Foo
),所以它们的最小上界是一个表示两个接口(Serializable
和Foo
)并集的类型。这种类型不能在源代码中定义,但Groovy知道它。
在集合类型推断(以及一般的泛型类型推断)上下文中,这变得很方便,因为组件的类型被推断为最小上界。我们可以在下面的例子中说明为什么这很重要:
interface Greeter { void greet() }
interface Salute { void salute() }
class A implements Greeter, Salute {
void greet() { println "Hello, I'm A!" }
void salute() { println "Bye from A!" }
}
class B implements Greeter, Salute {
void greet() { println "Hello, I'm B!" }
void salute() { println "Bye from B!" }
void exit() { println 'No way!' }
}
def list = [new A(), new B()]
list.each {
it.greet()
it.salute()
it.exit()
}
错误信息如下所示:
[Static type checking] - Cannot find matching method Greeter or Salute#exit()
这表明exit
方法既没有在Greiter
上定义,也没有在Salute
上定义,这两个接口定义在A
和B
的最小上界中。
在正常的、非类型检查的Groovy中,我们可以这样写:
class Greeter {
String greeting() { 'Hello' }
}
void doSomething(def o) {
if (o instanceof Greeter) {
println o.greeting()
}
}
doSomething(new Greeter()) //输出:Hello
方法调用可以工作是因为动态分派(方法是在运行时选择的)。Java中的等效代码需要在调用greeting
方法之前将o
转换为Greeter
,因为方法是在编译时选择的:
if (o instanceof Greeter) {
System.out.println(((Greeter)o).greeting());
}
然而,在Groovy中,即使在doSomething
方法上添加了@TypeChecked
(从而激活了类型检查),强制转换也不是必需的。编译器嵌入instanceof
推理,使强制转换成为可选的。
流类型是类型检查模式中Groovy的一个重要概念,也是类型推断的扩展。其思想是,编译器能够推断代码流中的变量类型,而不仅仅是在初始化时:
@groovy.transform.TypeChecked
void flowTyping() {
def o = 'foo'
o = o.toUpperCase()
o = 9d
o = Math.sqrt(o)
}
因此,类型检查器知道变量的具体类型随着时间的推移而不同。特别是,如果将最后的赋值替换为:
o = 9d
o = o.toUpperCase()
类型检查器现在将在编译时失败,因为当toUpperCase
被调用时,它知道o
是一个double
类型,因此这是一个类型错误。
重要的是要理解,使用def
声明变量并不是触发类型推断的事实。流类型适用于任何类型的任何变量。用显式类型声明变量只限制你可以赋值给变量的内容:
@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
List list = ['a','b','c']
list = list*.toUpperCase()
list = 'foo'
}
还可以注意到,即使变量声明时没有泛型信息,类型检查器也知道组件类型是什么。因此,这样的代码将无法编译:
@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
List list = ['a','b','c']
list.add(1)
}
解决这个问题需要在声明中添加显式泛型类型:
@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
List<? extends Serializable> list = []
list.addAll(['a','b','c'])
list.add(1)
}
引入流类型是为了减少经典Groovy和静态Groovy之间的语义差异。特别地,考虑这段代码在Java中的行为:
public Integer compute(String str) {
return str.length();
}
public String compute(Object o) {
return "Nope";
}
// ...
Object string = "Some string";
Object result = compute(string);
System.out.println(result);
在Java中,这段代码将输出Nope
,因为方法选择是在编译时根据声明的类型完成的。因此,即使o
在运行时是一个字符串,它仍然是被调用的对象版本,因为o
已经声明为对象。简而言之,在Java中,声明的类型是最重要的,无论是变量类型、参数类型还是返回类型。
在Groovy中,我们可以这样写:
int compute(String string) { string.length() }
String compute(Object o) { "Nope" }
Object o = 'string'
def result = compute(o)
println result
但这一次,它将返回6,因为所选择的方法是在运行时根据实际的参数类型选择的。所以在运行时,o
是一个字符串,所以使用了字符串变量。注意,此行为与类型检查无关,它是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()
} else {
o = new Bottom()
}
o.methodFromTop()
o.methodFromBottom() // compilation error
上述的代码执行后,会报编译错误。
当类型检查器访问if/else
控制结构时,它检查if/else
分支中赋值的所有变量,并计算所有赋值的最小上界。这个类型是if/else
块之后的推断变量的类型,所以在这个例子中,o
在if
分支中被分配了一个Top
,在else
分支中被分配了一个Bottom
。其中的LUB
是一个Top
,所以在条件分支之后,编译器推断o
是一个Top
。因此,允许调用methodFromTop
,但不允许调用methodFromBottom
。
对于闭包(closures),特别是闭包共享变量,也存在同样的推理。闭包共享变量是定义在闭包外部,但在闭包内部使用的变量,如下例所示:
def text = 'Hello, zinyan.com!'
def closure = {
println text
}
Groovy允许开发人员使用这些变量,而不要求它们是final
变量。这意味着闭包共享变量可以在闭包内部重新赋值:
String result
doSomething { String it ->
result = "Result: $it"
}
result = result?.toUpperCase()
问题是闭包是一个独立的代码块,可以在任何时候执行(也可以不执行)。特别是,例如,doSomething
可能是异步的。这意味着闭包的主体不属于主控制流。
因此,对于每个闭包共享变量,类型检查器也会计算该变量的所有赋值的LUB,并将该LUB用作闭包作用域之外的推断类型,如下例所示:
class Top {
void methodFromTop() {}
}
class Bottom extends Top {
void methodFromBottom() {}
}
def o = new Top()
Thread.start {
o = new Bottom()
}
o.methodFromTop()
o.methodFromBottom() // compilation error
在这里,很明显,当methodFromBottom
被调用时,不能保证在编译时或运行时o
的类型将有效地是Bottom
。这是有可能的,但我们不能确定,因为它是异步的。所以类型检查器只允许调用最小的上界,也就是这里的Top
。
所以上面的代码中,当我们调用methodFromBottom
后就会出现编译错误了。
本篇内容主要介绍了各种类型推断,以及相关推断的过程和Groovy处理逻辑。相关知识可以参考Groovy 官方文档:
Groovy Language Documentation (groovy-lang.org)
如果觉得本篇内容总结的还可以,希望能够点个赞鼓励一下。谢谢。
下一篇,是关于类型的最后一篇内容。