从Java和JavaScript来学习Haskell和Groovy(类型系统)

从Java和JavaScript来学习Haskell和Groovy(类型系统) 接上文《从Java和JavaScript来学习Haskell和Groovy(引子)》

首先搞清几个概念:

  • 动态类型(Dynamic Typing)和静态类型:区别的核心在编译期还是运行时。静态类型的语言系统在编译期就明确知道每一个变量的类型,如果发现不合法的类型赋值就在编译期报错;动态类型则直到运行时才会报错。
  • 类型推导(Type Inference),类型推断是指可以在上下文中,编译器来推导实际的类型,也就是代码使用隐式类型指定。比如一个简简单单的“var a=1”,a就被推断成整型。
  • 弱类型(Weakly Typed)和强类型:指的是语言系统对类型检查,或者是类型之间互相转换严格的程度。比如C和C++就是弱类型的,类型不安全,或者说类型转换其实是开放的,这个自由度带来的风险由程序员自己承担。

另外还有两个概念,Structural Typing(结构类型)和Duck Typing(鸭子类型),这两个都是面向对象里面的概念。如果两个类暴露的所有方法的签名都相同,那么可以说他们具备相同的结构类型(在《多重继承的演变》里面介绍过它)。鸭子类型的要求则宽松得多,如果两个类或者对象暴露的某个或者某几个方法具备一致的方法签名,比如这个方法表示鸭子的嘎嘎叫,那它们就都是能够嘎嘎叫的鸭子,而并不需要实现什么接口或者继承什么类。鸭子类型的使用多数出现在动态语言中。

把我们今天涉及到的语言放进去,来举几个具体的例子:

  • Java:静态类型+强类型+显式类型指定,具体什么类型代码里写得清清楚楚,引用类型更换的时候必须强制转换。
  • JavaScript:动态类型+弱类型+类型推导,可以把一个number赋给一个变量,接着可以再把一个string赋给这个变量而不会出错,但是这样就无法利用代码解释器的类型推断带来的性能上的好处了。
  • Groovy:动态类型+强类型+类型推导 或者 静态类型+强类型+显式类型指定(这两者取决于写代码的时候是使用关键字def还是使用传统的int、float这样的方式显式类型指定)。
  • Haskell:静态类型+强类型+类型推导,这也是作为纯函数式编程语言中“不变性”的一个表现。

数据类型

在Java中,有一些是非类非对象的原语类型,具体说就是int、float、double、long、boolean,这也是Java“不够面向对象”的一方面;其他类型,都可以归为“类”。

JavaScript的数据类型,其实和Java有点类似,存在一些类型不属于Object:

new String() instanceof Object  // true
new Array() instanceof Object   // true
new Number() instanceof Object  // true
new Boolean() instanceof Object // true
[] instanceof Object            // true
({}) instanceof Object          // true
"" instanceof Object            // false

其中,有两点需要指出:

1、如果直接写 {} instanceof Object 会报错,需要给这对大括号加上小括号,原因在这里有解释。

2、”"比较特别,它不是Object的实例,它也不是String的实例。看更多:

"" instanceof String    // false
[] instanceof Array     // true
true instanceof Boolean // false
/a/ instanceof RegExp   // true
3 instanceof Number     // false

所以,上面的 [] 不是Array实例,3 不是Number实例。左边的“literal”,和Java里面的“primitive type”有点像,不是“new”出来的,但是确实有够混乱的。还有一个相关的用来输出类型的关键字typeof:

typeof 3            // "number"
typeof ""           // "string"
typeof /a/          // "object"
typeof []           // "object"
typeof function(){} // "function"
typeof new String() // "object"

在Groovy中,一切皆对象。不但语法Java高度友好,而且看起来好像是把Java里面做得不足的地方都修正了,而原语类型已经被彻底干掉了,也没有JavaScript里面那堆混乱的定义。比如:

3.class

这会打印:

class java.lang.Integer

对于Groovy的类型,特别提一提Flow typing。在官网上给了一个很好的例子:

@groovy.transform.TypeChecked
void flowTyping() {
    def o = 'foo'                       
    o = o.toUpperCase()                 
    o = 9d                              
    o = Math.sqrt(o)                    
}

TypeChecked这个注解是要求编译器在编译期间就行使类型推断和类型检查。代码中,变量o发生了多次赋值,并且每次赋值的类型都不相同。在第一次赋值后,编译器认定类型是字符串,就容许了toUpperCase的发生;第二次赋值后,编译器认定类型是整型,于是sqrt方法的调用也合法了。也就是说,即便加上了静态类型推断和检查,这个推断和检查也不是只在第一次初始化发生的,而是贯穿在每次变量赋值之后。这就是在使用TypeChecked以后,Groovy和纯静态类型+类型推断的Haskell的区别。还有一个注解在编译期类型推断和检查能力更强,是“CompileStatic”,可以在编译期检查出元类(metaClass)操作带来的类型错误。

在对闭包参数进行类型检查时,有这样的例子:

void inviteIf(Person p, @ClosureParams(FirstParam) Closure<Boolean> predicate) {        
    if (predicate.call(p)) {
        // send invite
        // ...
    }
}
inviteIf(p) {                                                                       
    it.age >= 18
}

inviteIf方法接受两个参数,一个是Person对象p,一个是闭包predicate。其中的ClosureParams注解,用以明确告知predicate闭包将返回布尔类型,并且闭包接受的参数与闭包调用者的“第一个参数”一致,即Person类型。最后三行,做的是inviteIf的调用,传入p以及闭包实体。

再看Haskell,在ghci中使用 :t 可以输出类型:

:t ""    // :: [Char]
:t 'a'   // :: Char
:t 3     // :: Num a => a
:t True  // :: Bool
:t 3.3   // :: Fractional a => a
:t [1,2] // :: Num t => [t]
:t max   // :: Ord a => a -> a -> a

函数、类、接口和型别

Java中的类和接口,是区分的最明显的。所谓抽象类和接口的概念,是从C++的虚函数和纯虚函数演化过来的。函数是类和对象的附属物,无法独立存在。

JavaScript中,函数(function)终于成为了一等公民。既然是“一等”公民,自然和“二等”公民有所区别。在Java、C++这样的静态语言中,函数只能被声明和调用,只能依附在类的定义上面,无法像对象一样被传来传去,为此还孕育了一堆设计模式,看起来高大上了,其实是无奈为之。到了JavaScript中,函数可以像任何对象一样随意赋值,有自己的属性和方法,也可以被传来传去。这才有了这样的概念:

1、闭包:说白了就是带上下文的函数。也有人这样说,类是带函数的数据,闭包是带数据的函数。比如:

var out = function(val){
	this.in = function(){
		val++;
		console.log(val);
	};
};

var ins = new out(1).in;
ins();  // 2
ins();  // 3

这个例子里面,val完全是in这个函数的外部传进来的,这就是in这个函数的上下文,in在这里和它的上下文val一起,构成了闭包。

2、高阶(high-order)函数。高阶函数并非是函数式编程里面才有的概念,只要把函数作为参数传入函数,或者把函数作为返回值从函数传出,这就是高阶函数了:

var builder = function(type) {
	return function(number){
		console.log(type + ": " + number);
	};
};

var b1 = builder("cake");
var b2 = builder("cookie");
b1(1);  // cake: 1
b2(1);  // cookie: 1

但是JavaScript中对函数的控制有些特别的地方。比如,函数的定义方式给了两种,一种是直接声明,一种是表达式赋值,但是这两者被解释器处理起来的机制并不相同;再比如,函数的所谓“构造器”是和函数本身融合在一起的,不像C++或者Java里面,类定义是一方面,构造器是单独的方法。

Groovy对Java类型系统中的大部分保持兼容,但是做了改进,例如一切都是对象,例如上面提到的闭包、高阶函数这些函数一等公民的特性等等。值得一提的还有:

方法重载从编译时到运行时:方法重载的选择在静态语言里面全部都是编译期确定的,编译期认为参数的类型是什么就是什么,这是在编译期间就已经明确的事情(参阅《画圆画方的故事》,有一个很明确的例子),但是到了Groovy就变成了运行时决定——同为动态语言,它和JavaScript这种无法做到方法重载的语言又有所不同。下面这段代码,在Java中会返回1,在Groovy中返回0:

int m(String s) {
    return 0;
}
int m(Object obj) {
    return 1;
}
Object obj = "";
m(obj); // in Java: 1, in Groovy: 0

Haskell的类型系统比较复杂,一方面是本身包含的内容比较多,另一方面是函数式编程跳出了以往过程式语言或者面向对象语言的思维定势。没有了类和接口,除去一些和其他语言差不多的类型定义,有这样一些语言特性值得注意:

1、List Comprehension。比如这样:

[x*2 | x <- [1..10], x `mod` 2==0]
// [4,8,12,16,20]

竖线右侧有两个条件,一个是x来自于1~10这10个数(一开始我觉得从集合中取元素的左箭头“<-”的使用上有点反直觉,后来发现它其实就是数学中“属于”某个集合的表示符“∈”),另一个除以2的余数必须为0,满足这样条件的x的集合,每个元素再乘以2后返回。这样的数据集合表达式其实很清楚,而且很“数学”,因为这样的问题在数学中我一般会这样写,形式比较像:

y = x*2 (其中 1<=x<=10 且 x为整数 且 x为偶数)

下面写一个函数定义,执行的逻辑为上面操作的逆过程,即对传入的集合中的每个元素,寻找4的倍数,然后把它除以2,形成新的集合返回:

match :: [Int] -> [Int]
match xs = [ x `div` 2 | x <- xs, x `mod` 4 == 0]

其中的除以2操作要用div,而不是除号“/”,因为需要它返回整型。

在Haskell中集合操作非常常见,这和SQL很像,拿着一堆集合做各种运算。有个经典的例子是:

length' xs = sum [1 | _ <- xs]

其中,这个length’函数,求长度的原理是,把集合中的每个值都代换成1,然后求和。这和SQL中的select 1 from xxx再求和的写法没啥区别嘛。

2、模式匹配。这大概是Haskell中我最喜欢的部分。模式匹配在函数的定义里面使用起来简直太漂亮了。比如这个经典的阶乘例子:

factorial :: (Integral a) => a -> a  
factorial 0 = 1  
factorial n = n * factorial (n - 1)

其中的“factorial 0 = 1”这句,不就是我们通常写的函数里面,开始部分的“guard statement”么?

再比如这个求平面上两点之间距离的函数定义:

getDistance :: (Floating a) => (a, a) -> (a, a) -> a
getDistance (x1, y1) (x2, y2) = sqrt ((x1-x2)^2 + (y1-y2)^2)

还有通配符支持,比如:

translate :: String -> String
translate ('$':x) = "Dollar: " ++ x
translate (_:x) = "Unknown: " ++ x

Haskell里面区别这样几个重要的概念:

  • type(类型,也有翻译成型别):像Char、Bool和Int这种都属于type,函数也有类型,比如上面的“translate :: String -> String”。这非常容易理解,而typeclass则不然。
  • type variable(类型变量):比如上面提到过的“getDistance :: (Floating a) => (a, a) -> (a, a) -> a”,这里面的a,简直就如同Java里面的泛型参数啊,但又有很大区别,因为这里指规定了函数参数或者返回的取值类型,并没有约定“值”——这里参数和返回都是“a”,但是实际传入的参数和返回值却一般都是不相同的,只是类型相同而已。
  • type instance(类型实例):type的实例。
  • typeclass(类型类):和Java中的接口的概念有些类似,每一种typeclass都定义了某一行为,但是它并没有实现。我们可以说某一type“支持”或者“不支持”某一typeclass。比如“Show”就是一typeclass,类似Java中的toString方法,一般的type都支持这个行为。考虑到typeclass本身是一个表示行为的定义,一方面很像接口,另一方面又很像Java中的“重载”,同一个方法接受不同的type参数,执行不同的逻辑,而且同样是编译期确定。
  • kind:kind是type的类型,有点拗口,但就如同其它编程语言,“类的类型”的概念一样。比如执行“:k String”就会得到“String :: *”,这里的星号表示表示这个类型是具体类型。在Haskell的wiki上面,举了更多的例子(比如Maybe的kind是“* -> *”,就表示由一个具体类型去生成一个新的具体类型)。

看看下面这个例子,定义了type名为User,它的实例有两个,Engineer(有一个参数,name)和Manager(有两个参数,表示name和level):

data User = Engineer String | Manager String String deriving (Show)

现在定义一个方法打印name:

getName :: User -> String
getName (Engineer name) = name
getName (Manager name _) = name

getName (Manager "Tom" "L1") // "Tom"

也可以用命名参数的方式定义:

data User = User {
    name :: String,
    level :: String,
    age :: Int
} deriving (Show)

继承和接口实现

在Java中,继承和接口实现区分得最清晰,不同关键字,语义清楚。

在JavaScript中,没有接口的概念,而继承,严格意义上说也不存在,但是有实现类似继承效果的方法,我在这篇文章里面总结过。另外,由于动态语言的关系,可以给JavaScript的对象随时添加各种方法,具备额外的方法,实现继承或组合类似的功能,即便是JavaScript的原生对象和类也可以。尤其是在prototype上面增减方法,这在JavaScript中随着后来的演进慢慢引发了原型污染的问题。

Groovy中,继承和接口实现兼容Java的做法,而且由于和Java的同源性(全部编译成class文件在JVM上执行),Groovy实体类可以实现Java接口,而Java实体类也可以实现Groovy接口。

另外值得一提的是,对于不具备多重继承特性的语言,有很多都会提供弥补这一缺憾的方法(见此文介绍)。在Groovy中,有这样几个方法:

1、Mixin。比如注解实现:

class P {
    String test() { "test!" }
}

@Mixin(P)
class Target {
}

new Target().test(); // "test!"

也可以使用方法mixin来实现,原理上差不多,但这种方式就是run-time的了。

2、Delegate。也可以通过注解实现。在Groovy的官方文档中给了一个很好的例子,Date成员的方法被添加和绑定到了Event对象上面:

class Event {
    @Delegate Date when
    String title, url
}

def gr8conf = new Event(title: "GR8 Conference",
                          url: "http://www.gr8conf.org",
                         when: Date.parse("yyyy/MM/dd", "2009/05/18"))

def javaOne = new Event(title: "JavaOne",
                          url: "http://java.sun.com/javaone/",
                         when: Date.parse("yyyy/MM/dd", "2009/06/02"))

assert gr8conf.before(javaOne.when)

assert gr8conf.year + 1900 == 2009
assert gr8conf.toGMTString().contains(" 2009 ")

3、Traits。Trait内部可以定义抽象方法让子类去实现,trait本身无法实例化。

trait T1 {
    abstract String name()
    String test() { "test!" }
}
trait T2 {
    String test2() { "test!" }
}

class Person implements T, T2 {
    String name() { '...' }
}

new Person().test() // test!

Haskell的情况就更特别了,因为Haskell里面没有类的概念,但是有一些特性使用起来效果是差不多的。比如这个经典的例子:

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
    deriving (Eq, Ord, Show, Read, Bounded, Enum)

像继承一样了具备了Eq、Ord、Show等等数项行为,比如这之后执行“Monday==Sunday”就会返回False了。

方法覆写:接着刚才的例子,增加如下逻辑:

instance Eq Day where
    Monday == Monday = True
    _ == _ = False

这样遇到执行“Monday==Monday”的时候就返回True,其它所有情况都返回False。

关于编程语言的类型系统其实很复杂,我已经写得很费劲了,但是毕竟火候不行,还有一些重要或者深入的东西没有提到。另外,这也不是教程,只是按照特性的比较和整理,如果要系统学习Groovy或者Haskell,还是需要寻找相应的教程,通常在官网上的资料就很不错。下一部分将谈到这几门语言的元编程。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接《四火的唠叨》

分享到:

One comment

  1. 袁良锭 说道:

    楼主前面提到的关于结构类型和鸭子类型的资料能不能分享一些。

发表评论

电子邮件地址不会被公开。

您可以使用这些 HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>


Preview on Feedage: