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

Haskell 接上文 《从 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,还是需要寻找相应的教程,通常在官网上的资料就很不错。下一部分将谈到这几门语言的元编程。

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

6,152 次阅读

1 thought on “从 Java 和 JavaScript 来学习 Haskell 和 Groovy(类型系统)

发表评论

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

back to top