温故知新,跟着七牛云CEO许式伟学架构(02),地基之上编程语言诞生和进化
对于现代计算机来说,虽然CPU指令是一个很有限的指令集,但是CPU执行的指令序列(或者叫“程序”)并不是固定的,它依赖于保存在存储中的数据,由软件工程师(或者叫“程序员”)编写的软件决定。
计算机的程序可能被保存在计算机主板的ROM上(这段程序也叫计算机的启动程序),也可能被保存在外置的存储设备(比如硬盘)上,并在合适的时机加载执行。程序称得上是计算机的灵魂。指令序列的可能性是无穷的,程序的可能性就是无穷的。 今天计算机创造的世界如此多姿多彩,正是得益于程序无穷的可能性。
编程的史前时代
在第一门面向程序员的编程语言出现前,人们只能通过理解CPU指令的二进制表示,将程序以二进制数据方式刻录到存储(比如ROM或硬盘)上。这个时期的编程无疑是痛苦的,效率是极其低下的:且不说我们怎么去修改和迭代我们的程序,光将我们的想法表达出来就极其困难。我们首先要把表达的执行指令翻译成二进制的比特数据,然后再把这些数据刻录到存储上。这个时候软件和硬件的边界还非常模糊,并不存在所谓软件工程师(或者叫“程序员”)这样的职业。写程序也并不是一个纯软件的行为,把程序刻录到存储上往往还涉及了硬件的电气操作。
为了解决编程效率的问题,汇编语言(和解释它的编译器)诞生了。汇编语言的编译器将汇编语言写的程序编译成为CPU指令序列,并将其保存到外置的存储设备(比如硬盘)上。汇编语言非常接近计算机的CPU指令,一条汇编指令基本上和CPU指令一一对应。
与机器对话
汇编语言的出现,让写程序(编程)成为一个纯软件行为(出现“程序员”这个分工的标志),人们可以反复修改程序,然后通过汇编编译器将其翻译成机器语言,并写入到外置的存储设备(比如硬盘)。并且,程序员可以按需执行该程序。
在表达能力上,汇编语言主要做了如下效率优化:
-
用文本符号(symbol)表达机器指令,例如 add 表示加法运算,而不用记忆对应的 CPU 指令的二进制表示。
-
用文本符号(symbol)表达要操作的内存地址,并支持内存地址的自动分配。比如我们在程序中使用了“Hello” 这样一段文本,那么汇编编译器将为程序开辟一段静态存储区(通常我们叫“数据段”)来存放这段文本,并用一个文本符号(也就是“变量名 -variable”)指向它。用变量名去表达一段内存数据,这样我们就不用去关注内存的物理地址,而把精力放在程序的逻辑表达上。
-
用文本符号(symbol)表达要调用的函数(function,也叫“过程 -procedure”)地址。对 CPU 指令来说,函数只有地址没有名字。但从编程的角度,函数是机器指令的扩展,和机器指令需要用文本符号来助记一样,函数的名称也需要用文本符号来助记。
-
用文本符号(symbol)表达要跳转的目标地址。高级语言里面,流程控制的语法有很多,比如 goto、if .. else、for、while、until 等等。但是从汇编角度来说,只有两种基本的跳转指令:无条件跳转(jmp)和条件跳转 (je、jne)。同样,跳转的目标地址用文本符号(也就是“标签 -label”)有助于程序逻辑的表达,而不是让人把精力放在具体的指令跳转地址上。
汇编从指令能力上来说,和机器指令并无二致,它只不过把人们从物理硬件地址中解脱出来,以便专注于程序逻辑的表达。
可自我迭代的计算机
假设今天我们的信息科技的一切尚不存在,那么从架构设计角度,我们从工程上来说,如何更高效地完成从0到1的信息科技的构建?
Herman Hollerith设计的打孔卡片
最早的输入输出设备并不是键盘和显示器,而是打孔卡和打印机。用打孔卡来作为机器指令的输入,早在18世纪初就被用在织布机上了。早期的数字计算机就是用打孔卡来表达程序指令和输入的数据。
1886年,何乐礼完成了改良后的原型机,取名为「何乐礼电力制表系统」(Hollerith Electric Tabulating System)。经由毕林斯的协助,这部机器获得巴尔的摩公共卫生署(Baltimore Department of Health) 用来做死亡统计,取得不错的成效,于是随后纽泽西州以及纽约市的公共卫生署也陆续采购使用。从巴斯卡以降,多少计算机先驱充满期待却无法达成的事——计算机的商业化,终于在何乐礼手中达成。这当然要归功于何乐礼结合打孔卡片与电流控制,让计算机展现巨大的效能。但除了技术因素之外,还有一个成功关键在于新的商业模式。何乐礼并不是直接将机器卖给客户,而是透过西屋电气这样的大公司提供租赁服务,让客户每月支付租金即可。如此一来客户就不会因为购置金额庞大而裹足不前,何乐礼也能取得资金专心研发,瞄准最大的目标—— 1890年的人口普查。
我们可以想象一下,第一台以键盘 + 显示器为标准输入输出的现代计算机出现后,一个最小功能集的计算机主板的ROM上,应该刻上什么样的启动程序?换句话说,这个现代计算机具备的最基本功能是什么?
从高效的角度,它最好具备下面的这些能力:
- 键盘和显示器的驱动程序。
- 当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
- 一个汇编程序编辑器。
- 可从存储中读取汇编程序代码,修改并保存到存储中。
- 一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
- 可以执行一段保存在外置存储设备中的机器代码程序。
本质上,我们是要实现一个最小化的计算能力可自我迭代的计算机。
汇编语言的出现要早于操作系统。操作系统的核心目标是软件治理,只有在计算机需要管理很多的任务时,才需要有操作系统。所以,在没有操作系统之前,BIOS包含的内容很可能是下面这样的:
- 外置存储设备的驱动程序;
- 基础外部设备的驱动程序,比如键盘、显示器;
- 汇编语言的编辑器、编译器;
- 把程序的源代码写入磁盘,从磁盘读入的能力。
最早期的计算机毫无疑问是单任务的,计算的职能也多于存储的职能。每次做完任务,计算机的状态重新归零(回到初始状态)都没有关系。但是,有了上面这样一个BIOS程序后,计算机就开始发展起它存储的能力:程序的源代码可以进行迭代演进了。这一步非常非常重要。计算机的存储能力的重要性如同人类发明了纸。纸让人类存储了知识,一代代传递下去并不断演进,不断发扬光大。而同样有了存储能力的计算机,我们的软件程序就会不断被传承,不断演进发扬光大,并最终演进出今天越来越多姿多彩的信息科技的世界。
软件是活的书籍
软件是活的书籍,是我们人类知识传承能力的一次伟大进化。
书籍能够通过文字来记载事件、传递情感、揭示规律、传承技术。书籍能够让人们进行远程的沟通(飞鸽传书),也能够让我们了解古人的生活习性,与古人沟通(虽然是单向的)。
这些事情软件都可以做到,而且做得更好。为什么我说软件是活的书籍,有两方面的原因:
其一,表达方式的多样性。书籍只能通过文字描述来进行表达,这种表达方式依赖于人们对文字的理解,以及人的想象能力对场景进行还原。软件除了能够通过文字,还能够通过超链接、声音、动画、视频、实时的交互反馈等方式来还原场景。
其二,对技术的现场还原。书籍只能通过文字来描述技术,但是因为人与人对同样的文字理解不同,领悟能力不同,这些都可能导致技术的传承会出现偏差,如果文字的记载不够详尽,可能就会出现“谁也看不懂,学不会”的情况,从而导致技术的失传。但是,软件对技术的还原可以是精确的,甚至软件本身可以是技术的一部分。当软件是技术的一部分的时候,技术传承就是精确的,失传的概率就大大降低(除非技术本身适应不了潮流,退出了历史舞台)。
信息科技发展到今天,已经影响人类活动的方方面面。无论你从事什么职业,不管你是否会从事软件开发的工作,你都无法和信息科技脱节。如果希望能够站在职业发展的至高点,你就需要理解和计算机沟通的语言,也就需要理解软件工程师们的语言。不仅如此,如果你把编程语言升华为人类知识传承能力的进化,你就更能够清晰地预判到这样的未来:每一个小孩的基础教育中一定会有编程教育,就如同每一个小孩都需要学习物理和数学一样。
编程范式的进化
从思想表达的角度来说,我们通常会听到以下这些编程范式:
-
过程式。过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以过程式最为常见,每个语言都有一定过程式的影子。过程式语言的代表是Fortran、C/C++、JavaScript、Go等等。过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。通过结构体对数据进行组合,可以构建出任意复杂的自定义数据结构。通过过程可以抽象出任意复杂的自定义指令,复用以前的成果,简化意图的表达。
-
函数式。函数式本质上是过程式编程的一种约束,它最核心的主张就是变量不可变,函数尽可能没有副作用(对于通用语言来说,所有函数都没副作用是不可能的,内部有IO行为的函数就有副作用)。既然变量不可变,函数没有副作用,自然人们犯错的机会也就更少,代码质量就会更高。函数式语言的代表是Haskell、Erlang等等。大部分语言会比较难以彻底实施函数式的编程思想,但在思想上会有所借鉴。函数式编程相对小众。因为这样写代码质量虽然高,但是学习门槛也高。举一个最简单的例子:在过程式编程中,数组是一个最常规的数据结构,但是在函数式中因为变量不可变,对某个下标的数组元素的修改,就需要复制整个数组(因为数组作为一个变量它不可变),非常低效。所以,函数式编程里面,需要通过一种复杂的平衡二叉树来实现一个使用界面(接口)上和过程式语言数组一致的“数组”。这个简单的例子表明,如果你想用函数式编程,你需要重修数据结构这门课程,大学里面学的数据结构是不顶用了。
-
面向对象。面向对象在过程式的基础上,引入了对象(类)和对象方法(类成员函数),它主张尽可能把方法(其实就是过程)归纳到合适的对象(类)上,不主张全局函数(过程)。面向对象语言的代表是Java、C#、C++、Go等等。
从“面向对象”到“面向连接”
面向对象的核心思想是引入契约,基于对象这样一个概念对代码的使用界面进行抽象和封装。它有两个显著的优点:
-
清晰的使用界面,某种类型的对象有哪些方法一目了然,而不像过程式编程,数据结构和过程的关系是非常松散的。
-
信息的封装。面向对象不主张绕过对象的使用接口侵入到对象的内部实现细节。因为这样做破坏了信息的封装,降低了类的可复用性,有一天对象的内部实现方式改变了,依赖该对象的相关代码也需要跟着调整。
面向对象还有一个至关重要的概念是接口。通过接口,我们可以优雅地实现过程式编程中很费劲才能做到的一个能力:多态。由于对象和对象方法的强关联,我们可以引入接口来抽象不同对象相同的行为(比如鸟和猪是不同的对象,但是它们有相同的方法,比如移动和吃东西)。这样不同对象就可以用相同的代码来实现类似的复杂行为,这就是多态了。
多数面向对象语言往往还会引入一个叫继承的概念。大家对这个概念褒贬不一。虽然继承带来了编码上的便捷性,但也带来了不必要的心智负担:本来复合对象的唯一构造方法是组合,现在多了一个选择,继承。
Go语言是多范式更好的例子。它没有声称自己是多范式的,但是实际上每一种编程范式它都保留了精华部分。这并没有使得Go语言变得很复杂,整个语言的特性极其精简。Go语言之所以没有像C++那样声称是多范式的,是因为Go官方认为Go是一门面向连接的语言。
什么是面向连接的语言?
所谓面向连接就是朴素的组合思想。研究连接,就是研究人与人如何组合,研究代码与代码之间怎么组合。
面向对象创造性地把契约的重要性提高到了非常重要的高度,但这还远远不够。这是因为,并不是只有对象需要契约,语言设计的方方面面都需要契约。比如,代码规范约束了人的行为,是人与人的连接契约。如果面对同一种语言,大家写代码的方式很不一样,语言就可能存在很多种方言,这对达成共识十分不利。所以Go语言直接从语言设计上就消灭掉那些最容易发生口水的地方,让大家专注于意图的表达。
再比如,消息传递约束了进程(这里的进程是抽象意义上的,在Go语言中叫goroutine)的行为,是进程与进程的连接契约。消息传递是多核背景下流行起来的一种编程思想,其核心主张是:尽可能用消息传递来取代共享内存,从而尽可能避免显式的锁,降低编程负担。Go语言不只是提供了语言内建的消息传递机制(channel),同时它的消息传递是类型安全的。这种类型安全的消息传递契约机制,大大降低了犯错的机会。
其他方面的进化
除了编程范式,编程语言的进化还体现在工程化能力的完善上。工程化能力主要体现在如下这些方面:
- 包(package),即代码的发布单元
- 版本(version),即包的依赖管理
- 文档生成(doc)
- 单元测试(test)
从语言的执行器的行为看,出现了这样三种分类的语言:
- 编译的目标文件为可执行程序。典型代表是Fortran、C/C++、Go等。
- 生成跨平台的虚拟机字节码,有独立的执行器(虚拟机)执行字节码 。典型代表为Java、Erlang等。
- 直接解释执行。典型代表是JavaScript。当然现在纯解释执行的语言已经不多。大多数语言也只是看起来直接执行,内部还是会有基于字节码的虚拟机以提升性能。
语言对架构的影响是什么?
无论服务端,还是客户端,我们可以统一将其架构图简化为下图所示:
- 淡紫色是硬件层次的依赖,是我们程序工作的物理基础。
- 淡绿色的是软件层次的依赖,是我们程序工作的生态环境。
- 桔色的是库或源代码层次的依赖,是我们程序本身的组成部分。细分的话它又可以分两部分:一部分是业务无关的框架和基础库,还有一部分是业务架构。
描述每个模块的规格时,采用的规格描述语言会面临如下两种选择:
- 选择某种语言无关的接口表示
- 选择团队开发时采用的语言来描述接口
语言的选择在实践中对业务架构决策的影响仍然极其关键:
-
原因之一是开发效率。抛开语言本身的开发效率差异不谈,不同语言会有不同的社区资源。语言长期以来的演进,社区所沉淀下来的框架和基础库,还有你所在的企业长期发展形成的框架和基础库,都会导致巨大的开发效率上的差异。
-
原因之二是后期维护。语言的历史通常都很悠久,很难实质性地消亡。但是语言的确有它的生命周期,语言也会走向衰落。选择公司现在更熟悉的语言,还是选择一个面向未来更优的语言,对架构师来说也是一个两难选择。
怎么实现可自我迭代的计算机?
目前我们已知的功能需求有如下这些:
- 键盘和显示器的驱动程序。
- 外置存储设备的驱动程序。
- 汇编程序编辑器。可从外置存储中读取汇编程序代码,修改并保存到外置存储中。
- 汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到外置存储中。
- 支持执行一段保存在外置存储设备中的机器代码程序。
外置存储需要保存的内容有:
- 汇编程序的源代码
- 汇编编译器编译出来的可执行程序
操作系统的设计者们设计了文件系统这样的东西,来组织这些文件。虽然文件系统的种类有很多(比如:FAT32、NTFS、EXT3、EXT4 等等),但是它们有统一的抽象:文件系统是一颗树;节点要么是目录,要么是文件;文件必然是叶节点;根节点是目录,目录可以有子节点。但是,文件系统(File System)是否是唯一的可能性?当然不是。键值存储(Key-Value 存储) 也挺好,尤其是早期外置存储容量很可能极其有限的情况下。可以做这样统一的抽象:
- 每个文件都有一个名字(Key),通过名字(Key)可以唯一定位该文件,以进行文件内容的读写;
- 为了方便管理文件,可以对文件名做模糊查询(List),查询(List)操作支持通配符(比如我们现在习惯用的
*
和?
); - 未来外置存储的空间有可能很大,需要考虑文件管理的延展性问题;可以考虑允许每个文件设定额外的元数据(Meta),例如创建时间、编辑时间、最后访问时间、以及其他用户自定义的元数据。通过元数据我们也可以检索(Search)到我们感兴趣的文件。
BIOS和外置存储上的软件分工的标准是什么?
BIOS是刻在计算机主板ROM上的启动程序,它的变更非常麻烦。所以BIOS负责的事情最好越少越好,只做最稳定不变的事情。
首先是外部设备的驱动程序:键盘和显示器的驱动程序、外置存储设备的驱动程序。一方面,只要键盘、显示器、外置存储没有大的演进,驱动程序就不变,所以这块是稳定的;另一方面,它们是BIOS干其他业务的基础。所以,这个事情BIOS必然会做。
其次是汇编程序编辑器。编辑器的需求是模糊的,虽然我们知道它支持用户来编写程序,但是整个编辑器的操作范式是什么样的,没有规定。所以它不像是给键盘写一个驱动程序那样,是一个确定性的需求,而有很多额外的交互细节,需要去进一步明确。
再次是汇编编译器。汇编编译器从输入输出来看,似乎需求相对确定。输入的是汇编源代码,输出的是可执行程序。但认真分析你会发现,它实际上也有很大的不确定性。其一,CPU会增加指令,这时候汇编指令也会相应地增加。对于大部分应用程序,CPU新增的指令如果自己用不到,可以当它不存在。但是汇编语言及编译器需要完整呈现CPU的能力,因此需要及时跟进。其二,虽然汇编指令基本上和机器指令一一对应,但是它毕竟是面向程序员的生产力工具,所以汇编语言还是会演进出一些高阶的语法,比如宏汇编指令。所谓宏汇编指令,就是用一个命令去取代一小段汇编指令序列,它和C语言里面的宏非常类似。所以汇编语言并不是稳定的东西,它和其他高级语言类似,也会迭代变化。这就意味着汇编编译器也需要相应地迭代变化。
最后执行一段保存在外置存储设备中的机器代码程序。这个需求看似比较明确,但是实际上需求也需要进一步细化。它究竟是基于外置存储的物理地址来执行程序,还是基于文件系统中的文件(文件内容逻辑上连续,但是物理上很可能不连续)来执行程序?实现上,这两者有很大的不同。前者只需要依赖外置存储的驱动程序就可以完成,后者则还需要额外理解文件系统的格式才能做到。
BIOS到底怎么把执行控制权交到外置存储呢?
CPU加电启动时,它会从存储的一个固定地址开始执行指令,这个固定地址指向的正是BIOS程序。类似的,我们的BIOS也可以认定一个外置存储的固定地址来加载程序并执行,而无需关心磁盘的数据格式是什么样的。这个固定地址所在的数据区域,我们可以把它叫做引导区。引导区的存在非常重要,它实际上是BIOS与操作系统的边界。对于BIOS来说,执行外置存储上的程序能力肯定是需要具备的,否则它没有办法把执行权交给外置存储。但是这个能力可以是非常简约的。BIOS只需要执行引导区的程序,这个程序并不长,完全可以直接读入到内存中,然后再执行。我们是否需要基于文件系统中的文件来执行程序的能力?答案是需要。因为汇编编译器编译后的程序在外置存储中,需要有人能够去执行它。
BIOS需要负责的事情是:
- 键盘和显示器的驱动程序;
- 外置存储设备的驱动程序;
- 支持执行外置存储中引导区的机器代码程序;
- 跳转到外置存储的固定地址,把执行权交给该地址上的引导程序。
外置存储上的引导程序拿到执行权后干什么呢?
- 需要有人负责支持外置存储的数据格式,提供统一的功能给其他程序使用。无论它是文件系统,还是Key-Value存储系统。
- 需要有人提供管理外置存储的基础能力,比如查询(List)一下外置存储里面都有些什么文件。它可以实现为一个独立的程序,比如我们命名为ls。
- 需要有人执行外置存储上的可执行程序。它可以实现为一个独立的程序,比如我们命名为sh。
- 汇编程序编辑器。其实这个程序和汇编语言没什么关系,就是一个纯正的文本编辑器。我们可以把这个程序命名为vi。
- 汇编编译器。它可以实现为一个独立的程序,比如我们命名为asm。
引导程序拿到执行权后,我们不管它额外做了哪些事情,最终它要把执行权交给sh程序。因为,sh程序算得上是可自我迭代的计算机扩展性的体现:通过sh程序来执行外置存储上的任意程序,这也相当于在扩展CPU的指令集。
最终,我们设计出来的“可自我迭代的计算机”,它的系统架构看起来是这样的: