通过 Groovy 了解动态语言

2,356 阅读11分钟

3. Groovy 动态类型

所谓动态语言,指各种类型在程序的运行时进行推断,方法和实参也在运行时进行检查。这样,我们就能够在运行时向类注入行为,从而让代码和严格的静态类型相比有更好的拓展性。简而言之,利用动态类型,可以使用比 Java 更少的代码来实现更灵活的设计。

3.1 Java 中的类型检查

Java 是一门强类型的静态语言,细致到连一个分号都不漏掉。"牺牲自由以换取了最大安全"。大部分时间,Java 编译器就像一位啰嗦的老婆婆。在下面的代码中,虽然我们确信 super.clone() 的机制决定了它一定能够返回 User_ 类型,但是编译器仍然要求代码在此做一步显示地转换。

class User_ implements Cloneable {
    public String name;
    public Address_ address;

    @Override
    protected User_ clone() throws CloneNotSupportedException {
        return (User_) super.clone();
    }

	// 省略 构造器
	// 省略 toString
}

class Address_ implements Cloneable{
    public String street;
    public String houseNumber;

    @Override
    protected Address_ clone() throws CloneNotSupportedException {
        return (Address_) super.clone();
    }
	// 省略 构造器
	// 省略 toString
}

在调用克隆方法时,编译器也总是坚持要求我们处理这个受检异常 ( 除非给它抛到上级 ),尽管我们能确保这个 CloneNotSupportedException 不会发生。

try{
    User_ clone = user_.clone();
    System.out.println(clone.toString());
}catch (CloneNotSupportedException ex){
    ex.printStackTrace();
}

3.2 动态类型带来了什么

一些在 Java 编译器看来比较 "离谱" 的操作 ( 比如访问一个 Object 类型的 name 字段,或者去调用其它的 Object 本身不存在的方法),对于支持动态类型的语言来说,只要它在运行期间得到了正确的解释,那这个操作看起来就没有任何问题。

这些 "不应该存在" 的错误,编译器全部放弃了拦截 —— 它假定运行期间这些问题都会随着动态确认或者注入而被解决。其好处是当这个前提确定为真的情况下,我们可以 "面向直觉编程",并且不用再为了取悦编译器而一次次地添加显示的类型转换。

当然,这不是说动态类型就是万能的。我们先暂且抛出动态类型的风险和代价不提,一个智能又体贴的编程语言,当然谁都愿意用,比如隔壁的 Python。将开头的那个例子使用 Groovy 去实现,我们发现代码被简化了很多:

import groovy.transform.Canonical

@Canonical
class User implements Cloneable {
    String name
    Address address

    @Override
    def clone() throws CloneNotSupportedException {
        super.clone()
    }
}

@Canonical
class Address implements Cloneable {
    String street
    String houseNumber

    @Override
    def clone() throws CloneNotSupportedException {
         super.clone()
    }
}

user1 = new User(name:"Wang Fang",address: new Address(street:"LA",houseNumber: "132-052"))
user2 = user1.clone()

// 编译器不会报错,并且能够再运行时反馈正确的结果
// 唯一的坏处是,IDE 可能不会给出合理的代码提示。
println user2.name
println user2.address.street

// 这个方法印证了 user2 和 user1 不是同一个引用。
println user2.is(user1)

// 注意,Groovy 计算 HashCode 的方式和 Object 不完全相同。
// 因此如果比较 user1.hashCode() == user2.hashCode() 得到的结果是 true.
// 如果要在 Groovy 中根据传统的 hashCode() 比较两者是否为同一引用,那么需要令 User 主动调用来自 Object 的 super.hashCode() 方法。

首先,Groovy 不强制要求处理异常,我们首先节约了一段 try-catch 。其次,根据 clone() 方法的声明,user2 原本是一个 Object,但我们都知道它肯定是一个 User 类型。因此即使在没有任何强制转换的前提下直接访问 user2nameaddress 属性,Groovy 编译器也没有多加抱怨。

需要注意的是,Groovy 和 Java 一样都是强语言类型。如何判断 "强弱",目前的共识是:当发生类型不兼容问题时,程序是尽可能进行强制转换,还是直截了当的给出错误。如果是后者,那这门语言就是强类型1。其实,Groovy 和 Java 都是这么做的。而这对哥俩之间的区别是:Java 早在编译期就会指出不合理的类型转换,并提示:inconvertible types: can not cast 'XXX' to 'xxx' 。宽容的 Groovy 首先假设我们的所作所为是合理的,然后将这个检查过程推迟到了运行时期。

如果以静态,非静态,强类型,弱类型对编程语言进行划分,这实际上可以划分出四个区间。这里援引一张网络图片:

weak_strong.jpg

在这里需要指出 JVM 语言家族的一个小家碧玉:Scala。"偷天换日" 是它的拿手好戏,程序员可以先自定义一些隐式转换函数,再快乐地基于这些上下文规则进行 "弱类型" 编程,然后 scalac 会在编译期间会偷偷地调用转换函数来保持兼容性 ( 总之,编译器和人至少得有一方要多干些活 )。如此看来,Scala 其实也没有脱离静态强类型语言的范畴。

题外话,人们现在正围绕着 "Python 是强类型语言还是弱类型语言1" 而吵得不可开交。

3.3 能力式设计

Java 程序员将接口作为相互配合工作的契约 —— 它指定了契约里都有哪些任务,预期的结果应该是什么,同时,一个灵活的契约最好不要太过严格。Java 的接口提供了足够抽象的架构实现,但是仍然存在着一定限制。

假设我们现在正处在 Java 的世界观内,现在有一个 "挪动一个重物" 的需求,并针对它设计了一个抽象的 helpMoving 方法。如果我们的眼光放的足够远,那么就会将该方法放到一个接口当中等待其它热心群类去实现:

interface Helper{
    void helpMoving();
}

// Helper helper = new ...
// helper.helpMoving();

任何有能力搬动重物的类在定义时需要实现这个接口,无论它是 Man,Woman,Elephant,乃至更抽象的 Human,Animal。

而具备动态运行能力的 Groovy 却认为,只要能完成这项任务,那它就不必去关心谁会做,按照哪个契约做 —— 只要它有能力实现 helpMoving 方法,那就不在乎它是谁,实现了什么接口。

// Groovy 没有对 helper 做任何限制,因此它本质上是 Object 类型。
def takeHelp(helper){
	helper.helpMoving()
}

helper 本质上是 Object 类,我们在编写这个代码时,其实不保证它到底有没有合适的 helpMoving 方法 ( 在 Java 看来,这段代码简直错得离谱 )。不过,Groovy 相信我们会在运行时传入一个合理的 helper,因此它没有对此 "横加阻拦"。

helper 没有显示地实现 Helper 接口,在这里我们仅仅是依赖了它的某一个能力。这样的类在 Python 语言中被称之为 "鸭子类型",它的典故来自:"如果它走路像鸭子,叫声也像鸭子,那它就是一只鸭子"。

显然,当我们对 helper 的要求逐渐变得复杂起来时,传进来的对象不再需要从源码的层次中补充声明自己实现了这样或者是那样的接口。这样的做的结果就是少了繁文缛节,减少了开发成本。

3.4 合理对待动态类型

天下没有免费的午餐。动态类型在一定程度节约了代码量,但随着带来的代价之一则是测试成本的增加2。编译器不会再主动地替我们做一些检查,那么就意味着我们需要更多的单元测试来保证其运行时的安全性,否则就是在玩火。

假设用户确实有可能传入一个 "能力不足" 的对象呢?这个担心并无道理。Groovy 提供了一种方式让我们在运行时对 helper 做一些 "资格审查" ,比如:

// Groovy 没有对 helper 做任何限制,它本质上是 Object 类型。
def takeHelp(helper){
    // metaClass 的内容,以后再说。
    if (helper.metaClass.respondsTo(helper,'helpMoving')) {
        helper.helpMoving()
    }else throw new Exception('this helper has no capacity of \'helpMoving\'')
}

class Elephant{
    def helpMoving(){
        println "doing that"
    }
}

takeHelp(new Elephant())

3.5 有关 Groovy 的方法多态

首先需要了解一个大前提:通过继承关系实现的多态方法,Java 会在运行期间根据调用者的实际类型进行动态选择 ( 即 "动态绑定" )。这里给定几个类来举例子:

class Factory{
    public void make(Product product){
        System.out.println("make product by factory.");
    }
}

class ClothFactory extends Factory{
    @Override
    public void make(Product product) {
        System.out.println("make product by clothFactory.");
    }

    public void make(Cloth cloth){
        System.out.println("make cloth by clothFactory");
    }
}

class Product{ }
class Cloth extends Product{ }

动态绑定常用于解释对上转型对象的方法调用,比如下方代码块的 clothFactory 对象:

// 普通对象
Factory factory = new Factory();

// 上转型对象
Factory clothFactory = new ClothFactory();

// make product by factory.
factory.make(new Product());

// make product by clothFactory.
clothFactory.make(new Product());

然而在这个例子中,ClothFactory 类除了继承并重写了来自父类的 make 方法以外,自身还实现了接收 Cloth 类型的重载 make 方法。但是对于 Java 而言,只要 clothFactory 的引用是 Factory 类型,那么这个重载的 make 方法就永远无法被路由到,即便传入的是一个 Cloth 类型实例。

// 普通对象
Factory factory = new Factory();

// 上转型对象
Factory clothFactory = new ClothFactory();

// make product by factory.
factory.make(new Cloth());

// make product by clothFactory.
// 没有达到预期。
clothFactory.make(new Cloth());

可以这样理解:我们将 clothFactory 视作是一个 Factory 类型,因此在调用其 make 方法时,参数也总是被当作 Product 类对待,父类就是这么定义的。

Groovy 也许更懂开发者的心思。它的方法多态不仅和引用的实际类型相关,而且还和参数的实际类型相关 ( 不仅是方法多态,参数也变得 "多态" 了,这种方法分派基于多个实体,因此有个名词叫多分派或者多方法 Multimethods ) 。因此 Groovy 总是能找到最合适的方法来解决需求。下面的代码逻辑和上面完全相同,但是在 Groovy 这得到了 "更精确" 的执行结果。

class Factory{
    def make(Product product){print "make product by factory"}
}

class ClothFactory extends Factory{
    @Override
    def make(Product product) {print "make product by clothFactory."}
    def make(Cloth cloth){print "make cloth by clothFactory"}
}

class Product{}
class Cloth extends Product{}

Factory factory = new Factory()
Factory clothFactory = new ClothFactory()

// make product by factory
factory.make(new Cloth())

// make cloth by clothFactory
clothFactory.make(new Cloth())

3.6 重返静态类型

动态还是静态,Groovy 可以自由切换。至于如何选择,其实很大程度要看每个程序员的编程偏好。笔者已经习惯了 Java 风格,因此大部分情况下还是会选择严谨的类型检查,尤其是对类进行定义时。而在一些明显能推断出属性 / 返回值类型的地方,笔者会倾向于使用 def 关键字一笔带过。比如:

// Java:
// Tuple3<String,String,String> tps = new Tuple3<>("java","groovy","scala");
def tps = new Tuple3<>("java","groovy","scala")

下面有两个实用的注解能够让 Groovy 代码化动为静。

3.6.1 严格的编译时检查

@TypeChecked 是一个作用在方法或类上的注解。一旦被标注此注解,那么 Groovy 会在这段代码块内进行严格的类型检查,而不是基于 "运行时可能合理" 的假设。以前面的例子来说,@TypeChecked 注解将这个 "鸭子类型" 打回了原形 —— 编译器现在会报错。

import groovy.transform.TypeChecked

@TypeChecked
def takeHelp(helper){
    helper.helpMoving()
}

上面演示了该注解作用在方法上的情形。如果该注解作用到类上,则 Groovy 编译器会对其内部的所有方法,闭包,内部类全部进行严格检查。

3.6.2 静态编译

Groovy 的动态类型是以 "少许" 的性能做代价的 —— 尤其在 JDK 7 之前,JVM 还没有推出 invokeDynamic 字节码的那个年代。当时,对于那些追求 Groovy 的简洁,又纠结于运行性能的程序员而言,将 Groovy 干脆变成静态代码或许是更好的选择。如果这么做,就意味着舍弃了本章动态类型能够带来的所有便捷体验,以换取更高的运行性能。

import groovy.transform.CompileStatic
import static java.lang.System.currentTimeMillis

// 显示地标注参数,返回值的类型为基本数据类型 int。
@CompileStatic
int Fibonacci(int n){
    return (n<2) ? 1 : Fibonacci(n-1) + Fibonacci(n-2)
}

t1 = currentTimeMillis()
result = Fibonacci(40)
//result = Fib_more(40,1,1)
t2 = currentTimeMillis()

println "result:${result}"
println "time:${t2-t1} millis"


// 尾递归版斐波那契数列。
int Fib_more(int n,int left,int right){
    return (n == 0) ? left : Fib_more(n-1,right,(left + right))
}

上面的代码通过测试非尾递归的斐波那契数列来测试静态编译对执行性能的影响。如果函数标注了 @ComplieStatic 注解,那么该函数运行时长约为 300 ms;在不进行静态编译的情况下,该函数的运行时长约为 1200 ms,运行性能显著拉跨。

注:Groovy 的定位是一门脚本语言 ( 胶水语言 ) ,它其实不擅长做直接的计算密集型任务。到后面的学习会发现,Groovy 真正强大的地方在于元编程能力,我们得以用它去设计内部 DSL。思考一下,为什么现在的 AI 库核心选用 C/C++ 来完成,然后再使用 Python 在上面覆盖上薄薄的一层:这是出于性能和适用两方面考虑的决策。因此,可以类比着去思考在 JVM 上 Java ,Groovy 乃至 Scala 应该如何互相配合工作 ...... 或者你对 C 语言感兴趣吗?

3.7 参考资料

Footnotes

  1. Python 到底是强类型语言,还是弱类型语言? 2

  2. 笔者认为动态类型的两个代价是:安全和性能。