编程范式漫谈


最近在学习Golang时,发现自己对编程语言的理解还不够透彻。在Go的官网上写道Go(又称Golang)是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。虽然我知道Java是一种静态强类型解释型语言,但回头一想却没有仔细追究过静态,强类型,解释型代表的含义。编程语言种类繁多,静态动态,强类型弱类型,解释型和编译型,还有命令式编程和函数式编程。不好好梳理一下这些基础概念,会让我感觉自己的知识犹如空中楼阁,那种下不着地的感觉让人很不踏实。

概述

静态类型:在编译时变量的数据类型即可确定的语言,多数静态类型语言要求在使用变量之前必须声明数据类型,某些具有类型推导能力的现代语言可能能够部分减轻这个要求,例如在Java8中的泛型,List< String> list = new ArrayList<>()。由于前面指定了String类型,后面ArrayList就不需要指定类型了,编译器会推导出这个类型。

动态类型:动态类型语言是指在运行期间才去做数据类型检查的语言,也就是说,在用动态类型的语言编程时,永远也不用给任何变量指定数据类型,该语言会在你第一次赋值给变量时,在内部将数据类型记录下来。Python和Ruby就是一种典型的动态类型语言,其他的各种脚本语言如VBScript也多少属于动态类型语言。

强类型:强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。例如在Java中定义了一个整型变量a,那么程序根本不可能将a当作字符串类型处理。强类型定义语言是类型安全的语言。我们知道计算机是结构化很强的,堆栈上一个二进制位的错误就会导致溢出,bus等错误。所以语言层面的自由得益于编译器或者解释器的功劳。比如java,c等语言有很强的编译时类型检测机制,强类型的好处驱使编码人员写出很少有语法,语义错误的代码,对IDE的支持也很棒,是大型技术团队的基石。

弱类型:数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。这个在JavaScript中表现很明显,在JavaScript中声明变量时都可以声明为 var 类型。这种思维方式很接近自然语言了,我们日常沟通中文字就是文字并不需要区分整型,字符串型,布尔型。通常的说,Java/Python都算是强类型的,而 VB/Perl/C/JavaScript 都是弱类型的。弱类型语言让我们获取了自由(不需要类型信息),让开发者少敲了许多键盘。但自由是有代价的,编译器或解释器中内含类型推理(infer type); 类型推理是利用归一方法,基于上下文中的变量显式类型,操作符,返回值等信息,利用栈和逐渐替换的过程来推到出类型。 弱类型虽然可以轻松编译通过(或者不需要编译而是解释执行),但都是有类型检查过程的,只是将此过程延迟到运行时了。所以弱类型语言结构化不强,编码时很难确保类型无误,IDE,大型团队开发也不友好。 但是通过一些分析器可以不断的检测语法,语义错误,相当于达到了强类型语言的IDE效果。

编译型和解释型

我之前一直以为Java是编译型语言,因为每次运行程序都需要把工程先编译打成jar包,然后再去运行,但实际上Java是解释型语言,这也是为什么Java移植性强的原因。而实际上,很多高级语言都是解释型语言。Java、JavaScript、VBScript、Perl、Python、Scala、C#等。而编译型语言大多是偏底层的语言,如C/C++、Pascal/Object Pascal(Delphi)、VB。解释型语言和编译型语言区别只有一个,就是在把代码翻译成机器码的时机不同。先来解释一下这两个概念吧

编译型:编程语言在程序执行之前,有一个单独的编译过程,将程序翻译成机器语言(二进制代码),以后执行这个程序的时候,就不用再进行翻译成机器码了。举个例子,先看编译型以C语言为例子,C语言在工程写完之后也需要一个工具将代码翻译为机器码,这个工具叫 gcc 。这个过程说得专业一点,就称为编译(Compile),而负责编译的程序自然就称为编译器(Compiler)。如果我们写的程序代码都包含在一个源文件中,那么通常编译之后就会直接生成一个可执行文件,我们就可以直接运行了。但对于一个比较复杂的项目,为了方便管理,我们通常把代码分散在各个源文件中,作为不同的模块来组织。这时编译各个文件时就会生成目标文件(Object file)而不是前面说的可执行文件。一般一个源文件的编译都会对应一个目标文件。这些目标文件里的内容基本上已经是可执行代码了,但由于只是整个项目的一部分,所以我们还不能直接运行。待所有的源文件的编译都大功告成,我们就可以最后把这些半成品的目标文件“打包”成一个可执行文件了,这个工作由另一个程序负责完成,由于此过程好像是把包含可执行代码的目标文件连接装配起来,所以又称为链接(Link),而负责链接的程序就叫链接程序(Linker)。链接程序除了链接目标文件外,可能还有各种资源,像图标文件啊、声音文件啊什么的,还要负责去除目标文件之间的冗余重复代码,等等。链接完成之后,一般就可以得到我们想要的可执行文件了。

解释型:解释型语言,是在运行的时候将程序翻译成机器语言,所以运行速度相对于编译型语言要慢。这中间通常需要一个解释器。例如Java再经过JDK编译后,生成的是字节码,而不是机器码。字节码不能直接运行在操作系统上。因此需要经过解释器将字节码翻译为机器码,再交给操作系统执行,在Java中这个解释器就是JVM。翻译的时刻是在程序运行的前一刻,程序每执行到源程序的某一条指令,会有一个称之为解释程序的外壳程序将源代码转换成二进制代码以供执行,总言之,就是不断地解释、执行、解释、执行……所以,解释型程序是离不开解释程序的。像早期的BASIC就是一门经典的解释型语言,要执行BASIC程序,就得进入BASIC环境,然后才能加载程序源文件、运行。解释型程序中,由于程序总是以源代码的形式出现,因此只要有相应的解释器,移植几乎不成问题。编译型程序虽然源代码也可以移植,但前提是必须针对不同的系统分别进行编译,对于复杂的工程来说,的确是一件不小的时间消耗,况且很可能一些细节的地方还是要修改源代码。而且,解释型程序省却了编译的步骤,修改调试也非常方便,编辑完毕之后即可立即运行,不必像编译型程序一样每次进行小小改动都要耐心等待漫长的Compiling…Linking…这样的编译链接过程。不过凡事有利有弊,由于解释型程序是将编译的过程放到执行过程中,这就决定了解释型程序注定要比编译型慢上一大截,像几百倍的速度差距也是不足为奇的。

编译型与解释型,两者各有利弊。前者由于程序执行速度快。同等条件下对系统要求较低,因此像开发操作系统、大型应用程序、数据库系统等时都采用它。而服务器脚本及辅助开发接口这样的对速度要求不高、对不同系统平台间的兼容性有一定要求的程序则通常使用解释性语言。所以如果你愿意,你也可以自己用一门底层语言编写一个解释器,创造出一种高级语言。当然,现存的高级语言都是经历考验、受到广泛认可的,这才能流行起来,让大家来都遵循你的规范。代码世界里的规则完全是由人创造的,如果没有人遵循你创造的规则,那这个规则就只对你自己有意义了,对其他人则毫无意义。Java的编译器由C语言编写,C语言的编译过程有一步叫汇编,任何代码,最终都是要转化二进制命令来执行动作的,当然这个过程就在现今看来步骤就太多太复杂了。

命令式与函数式

命令式编程与函数式编程其实早在上个世纪就有了,一直都是命令式编程占据主导地位,例如Java,C语言都是命令式编程语言,不过最近几年函数式编程也异军突起,在Java8中也加入了函数式编程范式,Lambda 表达式。个人理解,在硬件架构上现在主流的计算机基本使用的是冯诺依曼架构,函数式编程与面向对象一样是对硬件工作方式的一种上层抽象,只不过两者思考问题的角度不一样。“函数式编程”, 又称泛函编程, 是一种”编程范式”(programming paradigm),也就是如何编写程序的方法论。它的基础是 λ 演算(lambda calculus)。λ演算可以接受函数当作输入(参数)和输出(返回值)。λ 演算可以被称为最小的通用程序设计语言。它包括一条变换规则 (变量替换) 和一条函数定义方式,λ演算之通用在于,任何一个可计算函数都能用这种形式来表达和求值。因而,它是等价于图灵机的。尽管如此,λ演算强调的是变换规则的运用,而非实现它们的具体机器。可以认为这是一种更接近软件而非硬件的方式。它一个数理逻辑形式系统,使用变量代入和置换来研究基于函数定义和应用的计算。希腊字母λ被用来在λ演算模型中表示将一个变量绑定在一个函数中。就像在OOP中,一切皆是对象,编程的是由对象交互创造的世界;在FP中,一切皆是函数,编程的世界是由函数交互创造的世界。

历史

在计算机科学刚刚起步的时候,很多计算机科学的理论都还没有落地。那时候有两个伟大的计算机科学家:阿隆佐·丘奇和阿兰·图灵。他们创造了两个不同的,但是具有同等效力的通用计算模型。两个模型都可以计算任何可以计算的东西。阿隆佐·丘奇发明了 λ 演算, λ 演算是基于函数应用的通用计算模型。阿兰·图灵则因图灵机而广为人知。图灵机定义了一个理论上的设备,它可以控制条带上的符号。他们合作证明了 λ 演算和图灵机是功能等价的。λ 演算全部都是关于函数组合。函数组合在软件开发中是非常富有表现力和说服力的。这里有三点关于 λ 演算的特殊说明:

  1. 函数通常是匿名的。在 JavaScript 中,const sum = (x, y) => x + y 的右边是匿名函数,即 (x, y) => x + y
  2. λ 演算中的函数只接受单一输入,它是一元的。如果你需要传递多参数,函数会接受第一个输入并且返回一个新的函数来接受第二个参数,以此类推。一个 n 元函数 (x, y) => x + y 可以表达为一个一元函数:x => y => x + y。这种 n 元函数到一元函数的转化叫做柯里化。
  3. 函数是一级的。意思是说一个函数可以作为另一个函数的输入,并且一个函数可以返回另一个函数。

老外做东西喜欢把一个系统抽象成一个模型,这种方式确实很不错,很大的简化了思考难度。我们从最底层的模型开始思考,在计算机系统模型中,由运算单元(CPU),存储单元(寄存器和内存),输入(键盘或鼠标),输出(显示器或打印机)组成。假设我们就只有这一堆硬件,我们想要这个计算机模型正常工作,需要人工把计算的过程送入CPU,人工把计算的中间结果送入内存,把最终的结果送入显示器并显示出来。太麻烦了,这些事情怎么自动化,多个计算任务怎么协调工作,于是就有了操作系统。操作系统就是硬件和软件的大管家,然后我们只要把计算的任务打好包(即可执行程序)交给操作系统运行就行了。但到这还只是解决了硬件层面的问题,那我们怎么把计算任务打包,CPU只认得二进制文件。我们当然只能把二进制的计算任务编写好后给操作系统,再由操作系统交给CPU执行了。但这对于人来说太难了,试想一下,一个文件中全是0和1 ,我们该怎么编写计算的逻辑呢,别说写了,就是看看也让人头痛吧。于是便有了编程语言,最开始的是汇编语言,是将一些二进制指令映射成助记符,极大的简化了开发过程,编写好程序后再由编译器翻译成二进制代码。当然汇编还是太复杂,科学家们又开始躁动了,于是就有了更高级的语言C,Java,Python等。语言的出现是本质上是为了解决编写计算任务(也就是程序)时使用二进制太难的问题。但实际上还有很多其他便利,现在知道了编程语言的必要性,试想一下,如果我们自己想发明一门语言首先要考虑的问题是什么呢?语法还是性能,我觉得应该是选择编程范式,即应该是以函数式还是以命令式作为语言的编程范式。这将决定了语言的编程思想,也就是你以什么样的方式去看待现实中的问题,并将这些问题转化到语言层面交给计算机解决。因此个人理解面向对象和函数式只是在不同的角度对待计算系统的问题。面向对象更关注于把计算过程中的一些属性和方法封装成对象,然后通过这些对象的交互解决问题,而函数式编程更关注于把复杂的计算过程拆分成一个个小的单一的计算方法(也就是函数),通过这些方法的层层调用使系统最终趋于稳定,解决问题,这里在拆分为函数时需要是一个纯函数(即相同的输入,永远会得到相同的输出,没有任何可观察的副作用)才满足函数式编程的思想。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
七层网络模型 七层网络模型
对于Java EE开发者来说,网络编程大多只需要使用HTTP通信协议就可以了,不需要关注协议具体的数据交换细节。不过随着时间的推移,我对这个过程越来越好奇,试想一下我在中国上海使用微信给一位身处美国洛杉矶的朋友发一条消息,这条消息是经过了哪
2019-07-28
Next 
硬盘的工作原理 硬盘的工作原理
硬盘是用于存储数据的硬件,内存也是存储数据的,但二者不同的是内存数据是掉电不保存的而硬盘数据是可以永久保存的,在读写速度上硬盘比内存慢几个数量级。硬盘也是和程序交互比较频繁的硬件了,经常在看一些数据存储相关框架的原理时,会对一些硬盘方面的专
2019-07-13
  TOC