如果远一点儿的话,这个主题可以从图灵机谈起。但是太过于抽象,我自己也恐怕驾驭不来,所以就用现在主流的寄存器机的工作原理来举例说明。
我们日常可见的计算机主要有两种 CPU 架构:Intel x86 和 ARM,前者如电脑,后者如手机,都使用了寄存器机的模型。站在 CPU 的角度,寄存器相当于我们说的内存,而 RAM 也就是我们平时说的物理内存,则是相当于外存。CPU 从内存中读入指令来执行,对寄存器中的数据进行运算,再把运算结果更新到内存中。对于 CPU 来说,内存中的所有数据都是二进制的数字,它并不知道哪些是代码哪些是数据,你让它当作代码它就去当代码去执行,你告诉它让它把某个地址的数据装入寄存器它就去读内存,你让它对寄存器作加法那它就去加。
但是对于人来说,某一块的数据只作为一种数据类型是不容易出错的,所以早期基本上就有了整数、浮点数(虽然不是从一开始就有的)、指针、字符串这几种最最基本的数据类型。于是抽象出了变量这个东西,它本身只代表了某一块内存地址,而类型系统则是告诉编译器,应该拿这块数据当做什么类型去处理。这样,当你拿一个变量去做一个不符合它数据类型的运算的时候,编译语言的静态编译过程就会报错:你这个变量不应该用来干这种事儿啊。早期的静态类型系统也是边界有些模糊的,为了使用方便可能自动把某一类型转换成另一种类型。比如早年的 C,编译器并不区分 char、int 和 bool(严格来讲不是 C 自带的类型)。
在实践中大家发现,有许多错误是这些不怎么严格的类型引发的。比如定义了一个常量字符串,在 x86 保护模式下,它是分配在只读内存区的,当你试图去更新某个字符的时候,它会引发一个异常。于是很快大家就发明了各种方式强化了类型系统,让编译器的静态检查发挥更多的作用,避免出错。比如 const char * 和 char * const,虽然这个方式好用,但是写起来却麻烦了不少。加上那个年代的内存比较金贵,IBM 觉得内存有640K的内存就已经完全满足需要了,大家都得尽量省着点儿用。所以甚至还有 union 这种一块内存数据可以代表多个不同数据类型的用法。想想现在动不动就 closure(闭包),得记录一大串的环境变量,只是为了一个不起眼的调用,实在是奢侈极了。大家千万不要以为我是在说 closure 错了,实际上在引入它之前,编程语言的发展都是小儿科,纯天然无污染,closure 才算是质的飞跃。
到这里我介绍的都是静态类型系统,声明一个变量的时候必须指定它的数据类型,并且无法更改数据类型。其中,强类型就是一个类型只能和确定的类型进行确定的运算,一般会有自动类型提升的支持,比如一个8位的整数和一个32位的整数运算,会自动把8位的提升为32位,或者是整数和浮点数运算,自动提升成浮点,但是反过来就不行。而弱类型呢,则是没那么严格,比如我可以直接拿一个字符型当作整数运算,严格意义上,比如把一个32位整数赋值给一个8位整数也是不行的,因为很可能会丢失数据成为 bug,必须通过显式转换来告诉编译器:我知道这样做的风险。
我们常见的脚本语言比如 JavaScript、Python、PHP,一般是动态类型系统,可以随时给一个变量赋任何值。所以脚本语言在赋值和运算的时候,必须随时进行检查,遇到不正确的使用方式就得报个错。这也是一般意义上脚本语言要慢于静态类型语言的根本原因:静态类型语言把类型检查工作都丢给了编译器,编译器检查类型没毛病,那理论上至少在类型这块就不会出错。但这并不意味着过了编译器你的代码就没毛病了,类型系统只是最最基本的把关,避免犯实在是太低级了的错误。至于你的逻辑实现有没有问题,鬼才知道。如果读过 GEB 的话,那么你应该会知道,哥德尔不完备定理早就在理论上证明了,没有办法证明你的程序是没有 bug 的。动态类型语言是变量可以改变类型,并不意味着没有类型。你可以理解成,一块变量会带着两个内容,一个是在脚本语言的运行期告诉脚本语言,这个变量是什么类型,另一个才是数据本身。脚本语言的强弱类型跟静态类型语言的相比,范畴还是有点儿差别的,尤其是弱类型语言。典型的就是 PHP,1和字符串'1'是相等的,甚至5和'5blahblah'也是相等的,这导致了一个很严重的问题,用 == 去判断两个字符串相等是一个富含 bug 的行为。虽然 JS 的类型也不怎么强,会出现 0 == '0'、!0 != !'0' 这种非常讨厌的结果,但至少人家的 2 == '2a' 是不成立的。这也是我非常不喜欢 PHP 的原因之一,用惯了强类型语言到了 PHP 里面按照习惯的写法到处都是坑。强类型的如 Python、Ruby 之类,使用起来就问题不大了。
还有一个不得不提的东西就是指针,相信当年学 C 的时候大家都云里雾里的,什么指针的指针,一看就晕了。其实指针这个东西也很简单,它只是保存一块数据指向一个内存地址,而指针的类型则是告诉编译器,指向的那块内存地址应该当做什么数据类型来使用。换句话说,它的数据部分只是保存了一个足够长的整数,而类型信息和普遍变量没差别,只是告诉编译器如何去看待它。当你把指针指向一个函数的时候,那就应该把它声明成一个回调函数指针。在编译语言发展史上,解决了内存使用的问题之后,如何更安全的使用回调函数就成了非常核心的痛点。可以说在 closure 出现之前的很长一段时间,都没有找到一个好的方式去处理,这里暂时按下不表,留到以后谈回调函数的时候再细说。
谈到指针的话,就不得不谈到另外一个指针使用时永远的痛,NULL 指针的问题。这个问题也是后来 Nullable 类型出现的原因,而现在的技术可以说已经比较好的解决了 NULL 指针引发的各种问题。这里也不打算细谈,虽然篇幅不一定会很长,但这它可以是非常有划时代意义的一个进步,值得单独拿出来说一下。
至于 struct,放到 Delphi 里面叫 record,则是把一组相关的数据放在一起的封装思想。我也不打算在这里谈,留到以后讲面向对象的时候再细说。
最后一个经典的类型就是数组了。静态数组、动态数组和指针,相信这也是早期学 C 的时候把大家折腾得够呛的概念。它的发展方式也都殊途同归,现在普遍也都使用了相同的解决方式。关于它的话题,我会在以后讲现在的基本数据结构的文章里细谈。
在语言的发展过程中,类型系统一直是各个语言难以回避的话题,不管是选择静态还是动态类型语言,强类型或者弱类型,都有当时的历史背景。如果要比较优劣的话,多年的实践之后,目前还是有一定比较客观的结论的。
- 弱类型基本上可以说是一种非常糟糕的实践。虽然有减少了显式类型转换的便利,但同时也引发了许多的坑。总体来说,新语言的类型系统是越来越严格,比如 JS 后来又发明了 === 和 !==,现在的代码主流风格是否定使用 == 和 != 的。但也有像 D 语言那样,我认为是过度设计的语言:mutable、const、immutable 然后又可以跟指针无穷嵌套,如果按照严格意义去写的话,导致写出来的代码无比麻烦。
- 静态类型系统可以通过编译期的检查,大大减少了犯低级错误的几率,在大型团队的开发中,具有不可替代的优势。这也是 TypeScript 作为 JS 的一门方言,竟可以跻身 github octoverse 2017 第11热门的语言的原因。同理,Python 也在3.5中加入了可以使用的类型提示语法。当然,用静态类型系统来写脚本语言的爽度是大打折扣,单人的开发效率也直线下降。但应用在水平参差不齐的大型团队中,静态类型检查带来的好处,还是远大于效率的损失的。当然如果是单人或者是规模很小的明星团队,不使用静态类型系统还是问题不大的。毕竟这种情况下一般还是尽快出活儿重要,就算出现了因为类型产生的低级错误,凭借明星成员的优秀 debug 能力,还是不太会影响软件进度的。
变量和类型系统是紧密不可分的,讲完了传统意义上的类型之后,就得提一下变量的声明与生存期的问题。
静态类型系统中的变量一定是需要声明的,而在许多脚本语言中,变量是不一定需要声明的,比如 PHP、Python 等等。一般来说,除了局部变量的生存期都是在当前的函数内,如果不在任何函数里那当然就是全局变量了。生存期顾名思义,出了这块代码它就活不成了,你就不能再使用它了。从 CPU 的角度来讲,就是在进入函数后在栈(stack,不知道为什么很多人喜欢叫堆栈,heap 是堆,stack 是栈明明很清楚,为啥要混一起)上划分一块空间,给这个函数使用。等函数结束后,这块空间很自然不再使用了。在不需要声明的语言中,在当前函数中使用一变量名时,会自动创建一个局部变量。当然也有混合的,在 JS 的早期设计中,如果没有声明一个变量而使用的话,它就会自动创造一个全局变量。很快大家就发现这是一个非常糟糕的设计,不经意的拼写错误会搞出全局变量,debug 的时候非常难发现这种错误。所以后来就有了 strict mode,再后来主流风格中连 var 本身也已经禁止使用,取而代之的是 const/let。像 PHP、Python 那种设计,进入一个函数之后,全局变量就不那么好直接用了,必须通过特殊的声明。因为不声明的话,解释器没办法判断你是想用上一层的变量,还是想再搞出一个局部变量,所以索性就都按局部变量处理了。个人觉得,还是声明一下变量用起来舒服一些,虽说现在免费代码编辑器都已经功能相当丰富,在自动补全功能的帮助下,不太容易出 typo,有的话那也基本上是从头到尾一直拼错的。毕竟声明了之后,在你使用 closure 之类的时候,还是有很大的便利性的。
接下来就是静态类型语言的自动类型推导(Type Inference)功能了。这个功能虽然在函数语言中早早就有了,但直到 C# 发明了 var 之前,似乎大家都没意识到原来的声明方式太啰嗦了。Java 作为落后语言的代表,也终于要在今年的版本10出,推出完整的类型推导支持了。这也是我非常非常讨厌 Java 的原因之一,以前那半拉科鸡的泛型推导跟没有一样,啰嗦得不要不要的。我觉得 Java 这个特性出来得太晚了,google 都已经在推 Kotlin 了,基本能跟 Java 一起无缝使用,渣渣语言终于该寿终正寝了。似乎微软最开始推出自动类型推导时,也并不是为了大家写着爽,而是因为要推 LINQ。然而 LINQ 这玩艺儿写出来爽一下,类型声明比 C 语言的回调函数还麻烦,一般人完全整不明白完全爽不起来,但是不要紧,编译器整得明明白白的。然后大家幡然省悟,为毛非得声明类型啊,一个等号后面表达式啥类型编译器比你明白多了。所以这之后的静态类型语言,全都上了自动类型推导,没有的也都默默给加上了,比如 D 语言的 auto。许多年之后,当我要在 Java 中声明并赋值一个嵌套的对象类型的时候,我心想这尼玛是给人用的么,完全是在浪费我的生命。也许有的人会说 Java 多么成功,然而2017年 github octoverse 的排名中,Python 已经干掉 Java 了,已经老三了,老三了!连老二都不是了,还有啥可吹的?以后有机会的话,也许我会专门写一下 Java 在设计上是多么糟糕的一门语言,但它又如何出色的解决了当时的痛点,以至于设计这么一门语言能够浮沉二十载,至今仍呆在老三的位置上。
就得提一下近年来另一个维度的对“变量”的约束。之所以给变量打引号,是因为它的涵义和过去的变量、常量有点儿不太一样,就是 mutable vs immutable。
如果你对 2015 年 JavaScript 推出的 ES6 语法有一定了解的话,那么你就一定会注意到 const/let。这个变化虽然看起来不起眼,却是从函数式编程语言(Functional Programming)里借鉴而来的概念,它其实大有来头。这几年的新语言,Rust、Kotlin、Swift,全都采用了类似的设计。Rust 里面用 let 默认声明 immutable 变量,let mut 才是 mutable;Kotlin 里面,val 声明是 immutable,var 是 mutable;Swift 里 let 是 immutable,var 是 mutable。immutable(不可变)变量类似于 C 里面 const 的作用,你必须在声明的同时给变量赋值,一旦赋值之后就无法再改变这个变量的值。然而它又不是常量的作用,因为它也是通过计算而来的,并不是数学意义上的常量。为啥说是 FP 的概念呢,函数式编程很大程度上是站在代数的角度出来来设计语言,代数里 a = 1 跟 C 里面 a = 1 的含义可不一样,a 既然是代表1了它就不能代表2,代学上从来没有一个标记一会儿是1一会儿是2这档子事儿。想想这也不奇怪,很多情况下我们使用变量的目的只是用于保存一个中间值,并不会变来变去的,如果它突然变了那我们反倒要很不安。你给它声明成 immutable 之后,那你就可以放心大胆的用它了,完全不用再担心过一会儿它的值就变了。事实上最近10年也是 FP 产生了巨大影响的10年,当我们使用 map、filter、reduce 这些函数式的调用的时候,也没必要创建那么多需要变的变量。有了 immutable 之后,不但你不会没事儿声明 mutable 变量了,编译型语言也知道它不会变了,优化起来很方便。虽说这个变化看起来影响不大,却在编程理念上造成了巨大的影响。我现在就觉得挺奇怪的,C 语言那时候内存少优化技术落后,全都变量也就算了,为毛80、90年代出现的流行的语言就没想到,按说优化起来更容易啊?还是数学家聪明,虽然语言不一定太好用,但人家从一开始就是正确的使用姿势。
所以,如果你有在用 ES 201x 的语法写 JS 程序的话,var 肯定是不能用了(以后会在介绍 closure 的时候讲一下为啥不能用它了),那么我建议你也尽量别再用 let 了——我现在基本上几千行 JS 下来都不一定会出现一个 let,当然这也是得益于 FP 的影响。
今天的类型系统和变量就先扯到这里了。下一篇文章会继续谈内存使用的另一大问题,动态内存的管理。