@Lenciel

编程语言里的类型系统

这两天看了「FP in Scala」的作者 Paul Chiusano 关于静态类型语言的优点的一篇blog。刚好最近在看很多 Scala 写的代码,仔细一想,好像大多数语言都在尝试把类型系统加强(不仅仅是 Scala、Swift、Rust 这些当红的,连Ceylon 这样的语言也越来越火了)。

所以也结合之前看过的一些文章(1, 2, 3, 4),对类型系统打个总结。

类型

「类型」,顾名思义就是一组可能的值的集合。比如整型,那么它可能的值是整数的集合;布尔型,它可能的值是 true 或者 false。我们可以定义任何的「type」,比如一个叫「ISO9001」的 type,它可能的值如果不是 ISO 就是 9001:这不是个整型,也不是个字符串型,它就是一个专门的特殊的类型。

静态类型语言里,变量的类型是确定好的:如果 x 是整型,那么如果你写了x=true这样的赋值,编译器在编译时就会报错。不同的静态类型语言有不同程度的表达能力,但是它们支持的类型是确定的。

动态类型语言里,对值进行了类型的划分:它知道 1 就是整型,true 就是布尔型。但是变量是什么类型却是不确定(动态)的。

静态类型语言

大多数静态类型语言需要做类型声明。比如 java 里面public int add(int x, int y)这样,对参数和返回值都需要声明类型。

也有一些静态类型语言不这么做,比如 Haskell 里面同样的函数写法是add x y = x + y。虽然没有显式地声明它类型,但是因为在这门语言里面,+操作符只能用在数值类型上面,所以 x 和 y 就都是数值类型的。需要理解的是,这种省略并不意味着静态特质的降低,相反,Haskell 的类型比 Java 要严格和强大得多。

声明了类型,在编译期就进行类型检查,如果不满足就报错,是静态类型语言一个很大的优点。比如大多数静态类型语言都不允许"a"+2这样的写法(C 语言很特立独行地支持了),每个静态类型语言里面的表达式,都在执行前就有一个确定的返回类型。

动态类型语言

像前面说的动态类型不需要声明类型,编译器也不会做推测,只在运行时才知道变量确切的值(这也是动态的意思所在)。

比如一个 python 函数:

def f(x, y):
    return x + y

它可以被用来做两个数相加,可以是字符串连接,甚至可以是 list 的合并。在运行时程序会检查 x 和 y 是什么类型的值。如果都是整型,就把数值加起来,如果是字符串,就把字符串粘起来,如果是一个整型一个字符串,那么很可能就要报错。

大多数动态语言,都会像 Python 一样,在运行时报出类型错误(JS 是个例外,它对任何表达式都会返回一个值,而不是报错),因此,使用动态语言,"a"+1这样的错误也需要在实际运行时才会被检查出来。

强类型和弱类型

术语「强类型(strong typing)」和「弱类型(weak typing)」是用得非常模糊的:

  • 有些时候,「强」表示静态类型。如果你在讨论或者是写东西的时候,要说的是静态类型语言,那就直接说,不要用强类型语言这样的术语
  • 有些时候,「强」表示不会做隐式的类型转换。比如 JS 里面允许"a"+1,其实内部是做了隐式的类型转换,这个时候人们说它是weak typing的,而不是强的。但是几乎所有的语言都会允许整型和浮点型相加,允许这个的语言是强还是弱呢?没有标准。人们这样说的时候,一般的意思是,如果这样的转换是不可接受的(会造成自己觉得很低级的类型错误在生产环境上运行时才被发现),就认为它是「弱类型」,相反,就觉得是「强类型」
  • 有些时候,「强」表示我们不能做这个语言规则不允许的类型转换
  • 有些时候,「强」表示是 memory-safe 的。C 就是典型的虽然是静态类型,但是不是 memory-safe 的语言。

下面这个表可以说明常见的一些语言是如何被带入一团迷雾的:

语言 类型? 隐式转换? 有类型转换规则? 内存安全?
C Strong Depends Weak Weak
Java Strong Depends Strong Strong
Haskell Strong Strong Strong Strong
Python Weak Depends Strong Strong
JavaScript Weak Weak Weak Strong

由于「强」和「弱」的使用是如此的上下文相关又如此的混乱,所以最好不要使用这些术语,而是描述具体的问题:「JS 在我们把字符串和整型相加的时候会正常返回,而 Python 会报错」比「JS 是弱类型的,而 Python 是强类型的」有意义得多。这样我们在讨论中就不会花时间去纠缠在本来就不是很清晰的术语上了。

就像 Chris Smith 写过的那样:

Strong typing: A type system that I like and feel comfortable with
Weak typing: A type system that worries me, or makes me feel uncomfortable

渐进的类型化(Gradual Typing)

我们能不能给动态语言添加静态类型呢?在有些情况下,我们可以;在其他情况下,这非常难,甚至是不可能的。

最明显的问题就是eval或者别的动态语言里面的类似的功能。1+eval("2")在 Python 里面会返回 3,但是1+eval(read_from_network())会返回什么?这要看 read_from_network()返回的是什么了。这种只有在运行时才知道结果的语句,是没有办法添加静态类型的支持的。

为了使eval()被合法使用,有个不太让人满意的做法就是把返回值设成 Any 这个 type,就好像很多 OO 语言里面返回 Object,或者是 Go 语言里面返回interface{}。之所以这不太让人满意是因为,这样一来,类型系统的功能也就被去掉了。所以如果一个语言有eval语法,同时又有类型系统,那么当你使用eval语句的时候,类型安全就不能被保证了。

有些语言允许所谓的 optional 或者是 gradual typing:在默认情况下,类型是动态的,但是你可以声明静态的 annotation。Python 最近添加了这个功能;Typescript 作为 Javascript 的 superscript 也有 optional types;Flow 会对普通的 javascript 代码做静态类型分析。这些语言之所以提供这些,是希望拥有一些静态类型语言的优势,但是和静态语言提供的类型保证相比,其实是很弱的:因为有些方法是动态类型的,有些方法又是静态类型的,程序员还需要自己来管理其中的差异化的东西。

静态类型语言代码的编译

静态类型的语言写的代码在编译的时候,编译器会先检查语法,然后检查类型。因此,有时候你修复了一个语法的错误,可能会看到一堆的类型的错误。这些错误并不是修复语法错误带来的,而是之前就有的,只是编译器没法在语法正确之前,去找出类型的错误。

一旦语法和类型都正确了之后,编译器就可以生成可执行的代码。静态类型的语言生成的代码执行起来通常比动态类型快:当你知道被加的是整数,你就可以使用 CPU 内置的 ADD 命令。如果需要动态的评估操作的是什么类型,要怎么返回或者是报错,都会花掉额外的时间。虽然有很多技术,比如 JIT(Just-In-Time)编译器可以在运行时收集到需要的信息后 recompile 一次生成比完全动态更快的代码,但是和静态语言比如 Rust 写出来的程序比,运行起来还是要慢一些的。

关于静态类型和动态类型的争论

静态类型语言的推崇者指出,如果没有一个严格的类型系统,那么一个微小的类型错误就可能导致生产环境崩掉。这当然是真的,所有使用过动态类型语言的同学肯定都遇见过。

动态语言的推崇者认为,动态语言写起来要容易一些。视你写的代码究竟用来干嘛而言,这可能是对的,也可能是错的。Rich Hickey 关于「easy」有个很经典的讲座,特别清楚的阐明了「easy」和「simple」的关系,以及你为什么要小心「easy」。不同的动态语言的设计者,对类型系统的考虑也就视他们想开放多少控制权给开发者自己控制,实现得非常不同。

比如,Javascript 的策略是尽量继续执行,即使"a"+1这样的语句明显不是很合理了,它也会返回a1。Python 则倾向于尽可能的报错。所以虽然都是动态语言,设计上的思路是非常不同的。

比如,C 允许你从内存的任意地址读取,也允许你把一个类型的值当成任意别的类型来操作,即便这样会造成 crash。Haskell,则要求哪怕是整型和浮点型相加,也需要显式的做一次类型转换。所以虽然都是静态语言,设计上的思路也是非常不同的。

因此,任何类似于「静态语言比动态语言在某个方面好」的论断都是没有意义的。只有具体到语言,才可以进行这样的讨论,比如:「Haskell 在某个方面比 Python 做得好」。

静态类型系统的多样性

我们再来仔细看看两个著名的静态类型语言:Go 和 Haskell。

Go 的类型里面没有 generic 类型:就是由其他类型来构成的类型。比如我们可能想自己构建一个 MyList 类型,可以保存任何类型的数据列表,可以是整数的列表,也可以是字符串的列表等等。编译器自己需要来处理对类型的限制:如果我们往一个用来放整数的 MyList 里面放了字符串,编译器需要拒绝程序。

Go 在设计上就故意没有支持 MyList 这种 generic 的类型。要实现类似的功能,你只能定义一个「empty interface」:这样 MyList 可以用来放任何类型,但是 compiler 没有办法知道究竟是什么类型。当我们从 MyList 里面获取对象的时候,我们需要自己来告诉 compiler 对象的类型。如果你说你获取的是个字符串,但是拿到的是个整型值,就会发生运行时的错误:这很像动态语言。

Go 还缺乏很多现代静态语言的功能,甚至连很多 70 年代的静态语言就支持的功能也没有。它的设计者做这样的决定自然有自己的考虑,但是使用者里面对这些功能的呼声是很高的。

再来看看拥有强大的类型系统的 Haskell。如果我们要定义一个整型的 MyList,只需要这么写「MyList Integer」。在声明之后,Haskell 就会让这个列表里面只能放整数而不是字符串了。

Haskell 还能表达更复杂的语义。比如Num a => MyList a表示,一个数值类型的列表:可以是整数,或者浮点数等,但不会是一个字符串。

用 Haskell 你很容易编写适合于多个类型的函数。比如Num a => (a -> a -> a)表示:

这是个数值类型的函数(Num a =>) 这是函数有两个类型为 a 的输入参数,然后返回一个类型为 a 的值(a -> a -> a)

再看一个可能更夸张的例子:String -> String表示函数的参数是字符串,返回也是字符串;而 String -> IO String表示,函数参数是字符串,然后要做一些 IO 操作(可能是磁盘的 IO,也可能是网络的 IO 等等),然后返回字符串。

这样精细的类型定义有什么好处呢?

比如,在写一个 Web 应用的时候,我们就可以一眼看出这个函数会不会动数据库。这是大多数语言没有办法提供的便利,即使大多数静态类型语言也没有办法提供:绝大多数语言需要我们一行行去检查它有没有 IO 的操作,这个过程既麻烦又容易出错。

和 Go 比一下,前者连 MyList 那样简单的概念都没有办法很好的表示,更不用提「一个有两个数值型参数的,会做一些 IO 操作后,返回一个数值类型参数」这样复杂的语义了。

Go 的设计思路毫无疑问可以让使用它编写适合用它编写的程序变得容易(首先,设计 Go 的 compiler 就要容易很多),同时也使得学习 Go 变得容易。这些优势,与 Go 的显而易见的限制,究竟会给你的开发工作带来正面还是负面的影响,很多时候是跟问题域相关的,非常主观的事情。

Go 和 Haskell 如此的不同,也使得把它们统称为「静态类型语言」表达的意思非常模糊:虽然这样说确实是没有错的。所以再次提醒,把动态类型语言和静态类型语言分成两组来比较一些东西的时候,一定要记得语言的多样性。比如我们说运行起来的安全系数(不发生类型错误导致 runtime erro 的系数),那么 Go 和 C,甚至比 Python 这样的动态语言问题还大。

不同的类型系统表达能力的实例

编程语言的类型系统越强大,我们表达的粒度就可以越精细。

比如写一个求和的函数,在 Go 里面,我们只能表达「函数有两个整数类型参数,并且返回一个整数类型」:

fuc add(x int, y int) int {
     return x + y
}

对浮点类型,我们可能就需要再写一个。

在 Haskell 里面,我们可以定义「函数有两个数值类型的参数,并且返回一个和输入参数类型相同的类型」:

add :: Num a => a -> a -> a
add x y = x+y

而在 Idris 里面,我们可以定义「函数有两个整数类型参数,但是第一个必须比第二个小」

add : (x : Nat) -> (y : Nat) -> {auto smaller : LT x y} -> Nat
add x y = x + y

我们如果在调用的时候写了add 2 1,那么编译器就会直接报错。非常少语言有这样的表达能力,大多数时候我们要做类似的 check 只能在 runtime,所以我们只能写类似于if x>=y : raise SomeError()的语句。

所以,越强的类型系统,表达能力就越强,但是也要注意的是这样语言的复杂度也上去了。

我们可以看看按类型系统由弱到强排列的一些常见的编程语言:

  • C (1972), Go (2009): 类型系统很弱,甚至连 generic 的类型都不支持
  • Java (1995), C# (2000): 支持 generic 的类型
  • Haskell (1990), Rust (2010), Swift (2014): 提供了更强大的类型系统
  • Agda (2007), Idris (2011): 类型系统更加强大,但是学习的人还很少(虽然学了的人都表示亢奋)

随着时间推移,具备更强大的类型系统的语言有着越来越流行的趋势(这个趋势从很多动态语言里面被加入了 gradual typing 的功能也能感受到)。而火爆的 Go 应该是一个蛮特别的反例,它也被很多推崇更强大类型系统的静态语言用户者批判它的设计者在开倒车。第二组里面的 Java 和 C#是目前被广泛使用的,有成熟生态系统的语言。第三组是目前有了进入主流趋势的,有着大公司在背后支持的(比如 Mozilla 的 Rust 或者是 Apple 的 Swift)。第四组看起来还离主流很远,但是究竟后面会不会有凶猛的发展势头也很难说清:就像第三组里面的这些语言十年前也没人知晓一样。

下面,让我来一段魔性的代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int addi(int a, int b) {
    return a + b;
}

char *adds(char *a, char *b) {
    char *res = malloc(strlen(a) + strlen(b) + 1);
    strcpy(res, a);
    strcat(res, b);
    return res;
}

#define add(a, b) _Generic(a, int: addi, char*: adds)(a, b)

int main(void) {
    int a = 1, b = 2;
    printf("%d\n", add(a, b)); // 3

    char *c = "hello ", *d = "world";
    printf("%s\n", add(c, d)); // hello world

    return 0;
}

如何带娃

是一个全世界人民,特别是苦逼的中间阶层人民,亘古不变的热闹话题。

昨天,远在 LA 的陈明同学分享了一个关于如何带娃的TED讲座

讲座里面酷似乌比·戈德堡的阿姨把那种生活在完成家长制定的无穷无尽的计划中的小孩,叫做 checklist children。她认为这些家长进行的是过度抚养(不知道这么翻译 over-parenting 是不是正确),并对有此遭遇的小孩致以了深切的同情:因为总是被安排,没有自己成长的空间,这些小朋友们感受不到父母对自己的爱,从而也就没有办法正确地认识自己和爱自己,于是更加没有办法好好对待别人和爱别人。

这时候下面的观众应者寥寥,大概跟本座的心情是一样的:「这很有道理,然而社会竞争如此激烈,讲爱啊什么的并没有卵用。」

然而她给出的解决方案还是靠爱:「用无条件的爱,而不是 checklist,来对待自己的孩子。」

其中有句话很动人:「我们每个人对待自己的小孩,不要想着他们应该读什么声名显赫的大学才争气,而是像他们刚刚出现在你的生活里面的最初那几秒或者那几分钟一样纯粹就好了。」

大概是很多人都想起来了这最初的奇妙心情,从这句话开始,大呼小叫的支持声就一直不断了。

但我很怀疑这样的讲座对美帝中产社区里面拼得头破血流的家长们能有多少实际意义。

更不用说我大天朝的苦逼家长们。

本着治病救人要连根拔起的精神,不如本座来给大家开一铺权威猛药。

上周在 nature 网站上,我看到了一篇文章,讲述一个长达 45 年的天才少年跟踪计划SMPY,以及这个计划得出的结论。

这个计划的创办其实也挺偶然的:1968 年的一个夏日,Julian Stanley 接手了计算机系的一名神童 Joseph Bates。这个来自美国巴尔的摩的学生在数学等方面遥遥领先于同龄人,于是他的父母送他到 Johns Hopkins University 学习大学计算机课程。结果他的水平超越了班上绝大多数成年人,在成为了他们的 Fortran 语言助教之后,表示课程有些无聊。

不知道该拿 Bates 怎么办的计算机系,就只好把他介绍给了因为在心理测量学方面的工作而享有盛誉的 Stanley。为了了解这个小神童点了什么天赋,Stanley 让 Bates 接受了一系列测试,包括美帝高考 SAT 考试。

测试的结果是,Bates 的 SAT 得分远超 Johns Hopkins University 的录取门槛。这一方面让 Bates 得以在 13 岁作为本科生正式进入这所大学;同时,他也成为了 Stanley 的「数学早慧少年研究」(Study of Mathematically Precocious Youth, SMPY)的「student zero」(跟 EVA 一样,都有零号机啊有没有)。

Stanley 开启这项研究,很大程度上是因为心理学领域最著名的纵向调查之一——Lewis Terman 的「天才遗传学研究」(从 1921 年开始,Terman 基于 IQ 测试得分选择了一批青少年受试者,然后追踪并为他们的事业提供鼓励)仅产生了为数不多的德高望重的科学家。而年少时因 IQ 得分过低而被淘汰的候选者里面,却涌现了包括晶体管的共同发明者、诺奖得主 William Shockley,物理学家、诺奖得主 Luis Alvarez 这样的选手。

Stanley 于是想找出更加科学的筛选和培养方式,用他的话说就是:「no more dry bones methodology」(拒绝血统论?呵呵…)。

这项随后持续长达 45 年的研究,也确实改变了天才儿童在美国被筛选和培养的方式。约 5000 多人在该计划中被持续跟踪,其中就包括了数学家陶哲轩和 Lenhard Ng,Facebook 创始人 Mark Zuckerberg,Google 的联合创始人 Sergey Brin,以及音乐家 Stefani Germanotta(我们都知道的 Lady Gaga)等等成就卓越的人。

Nature 的那篇文章本身挺精彩的,有志于培养天才儿童,和有志于不培养天才儿童的家长,都可以仔细读一下。这里只摘大概是家长们最关心的。

它最主要(大概也是最有争议)的结论非常简明:

Such results contradict long-established ideas suggesting that expert performance is built mainly through practice — that anyone can get to the top with enough focused effort of the right kind. SMPY, by contrast, suggests that early cognitive ability has more effect on achievement than either deliberate practice or environmental factors such as socio-economic status.

也就是说,虽然长期以来人们坚信要在某方面有卓越表现,主要是靠后天不断努力练习。但这些数据却说明,天赋比努力和环境都重要:就像有些人并不需要练习就可以在某项运动中轻松取胜一样,有些人其实不需要特别努力就在某些学术领域非常厉害。

以此为基调,研究还提出了「有天赋的小孩就要特殊培养,和普通教育分开,不然就出不了成绩」这个同样充满争议的论断。为了应对诸如「跳级会让孩子心理出问题」这样的论断,他们甚至专门对跳过一级的儿童和同样聪明但未跳级的对照组进行了比较,并指出前者获得博士学位或专利的可能性比后者高出 60%,而且在取得成就之后活得非常健康。

这些孩子通常并不需要任何有创新性或者新奇的东西。他们只需要尽早获取年龄较大的孩子已经接触到的内容。

文章后面花了不少篇幅去讨论当「天才是可以被挑选,被培养,并取得成效」这件事情被证实之后,不同社会,特别是不同社会的教育界相应的反应。比如中东和亚洲就更愿意把所有资源向前面这 1%的孩子倾斜,成绩不好的小孩儿没人理;而欧美就有些反过来,为了班上最慢的孩子跟上,老师可以调整节奏来让全班适应他一个人:

「the focus has moved more towards inclusion」

这里的inclusion用得很妙,杰出的孩子是exclusive没错,但作为普通人的学校,任务是要带上大家。

今天我们要怎样做家长?

社会要怎么对待不同能力的小孩是教育界的话题。我们自己要怎么做家长呢?

个人觉得,在接受天赋比努力、比环境还重要之后,家长可以做的最主要的转换就是,从让孩子「练出一身特长」,到「找到特长所在」。

不要觉得你的小孩不是那顶尖的 1%,SMPY 的研究者也说, 他们找 STEM 栈(Science, Technology, Engineering, Mathematics)厉害而已,其实很多别的小孩有别的天赋。

生产力发展到今天,特别是拜互联网所赐,现在要体验某个领域,比以前的门槛低了很多很多。有了 VR,AR 等技术之后,低成本的沉浸式的体验也会走入千家万户。

与此同时,要学习某个技能的门槛比以前低了更多。一方面,获取信息极为便利,大多数领域的基础学习都可以免费完成;另一方面,检索和调取信息非常高效,花在「背」这部分的时间大幅度缩减。

因此,找到你的小孩适合学什么大概成了整个作为家长在教育里面要解决的最重要的问题。

所以,过去的家长大概养小孩儿是跟样盆景差不多的:按照自己的科属、盆子的大小和见过的实例,家长对自己的孩子不断施肥、除虫、修枝剪枝。这当然很容易就会有 TED 讲座里面说的 over-parenting 的问题:毕竟这盆景再好看,究竟是家长觉得拿出去风光,还是小孩子自己觉得舒服,很难讲。

今后就更应该像养野花…先呼啦啦长得满坑满谷…然后再去收拾。

要做到这样,略不容易。

不过 nature 那篇文章里面正好给出了八项让「聪明的小孩既快乐又有成就」的具体建议:

  1. 让孩子体验各种不同的事物
  2. 当孩子表现出强烈的兴趣或天分时为他提供发展兴趣和天分的机会
  3. 除开关注孩子智力发展,也要关注他精神方面的需求。
  4. 通过赞扬他的努力,而不是能力,来帮助孩子形成成长性的思维模式。
  5. 鼓励孩子进行智力上的冒险,并放宽心态对待,让他们学会吸取失败的经验。
  6. 切忌贴标签,被贴上神童的标签可能造成精神上的负担。
  7. 和老师合作满足孩子的需求,聪明学生通常需要更难的学习、更多的支持或能按自己的节奏学习的自由。
  8. 检验孩子的能力,这可以作为要求超前学习的佐证,也有助于及时发现失读症、过动儿之类的问题,或者社交和精神上的困难。