编程语言之我思-通识

为了使文章更有针对性,先抛出本系列文章想解决的问题:
作为一名程序猿,如何快速学习并掌握一门新兴的计算机语言?

本文作为系列第一篇,专注于解释我理解中计算机语言的通用知识。

解决思路

在人类的认知过程中,分类和比较是两大基本能力:把一个需要解决的问题通过和已知问题做比较,拆成不变和变化的两个部分,把精力和资源主要放在变化的那部分上,最后将变化的学习成果和不变的部分进行综合,是一种有效的学习手段。
把上述方法推广到学习计算机语言的过程中,即在了解各种语言通用知识的基础上,着重观察每个语言与众不同的部分,从而做到有的放矢,游刃有余。

通用知识 - 定位,分类和评估

计算机语言的定位

计算机语言是我们操作计算机(与其沟通)的工具,针对冯诺伊曼体系而言,执行一段计算机语言编写的程序的最终结果是操作这台计算机的物理资源(CPU/内存等)。

工具的作用

接下来先探讨一下计算机语言作为工具所处的使用环境和作用。让我们拿着一把名为“由简到繁”的放大镜来看看吧:

阶段一:最最朴素的情况

即使用和物理机器CPU绑定的机器代码实现程序逻辑,进而操作物理机器,在历史上,这一幕真实存在:
打孔编码->将卡片塞进机器->启动机器->程序运行->运行结束->机器停止->查看结果

想象一个场景S:在我们面前的是一台具备语音指令功能的机器,我们可以通过和它对话来操作它,坑爹的是机器可以识别的语音指令(机器语言)非常原始:

  • N多指令才能表达一个操作
  • 每台机器识别的指令还不一样

阶段二:呃,让我们雇佣几个帮手吧

有请管家登场

在现实场景中,由于

  • 每个人的程序为了完成实际任务基本都会和各种系统资源产生交集(例如都要从键盘采集输入,从显示器输出等),如果每个人都独立编写程序所有功能的话,不可避免会有很多冗余代码出现,且无法保证这部分代码的性能
  • 现代机器性能非常强悍,即使是单核CPU也完全可以通过分时模拟多人环境(同时执行多个任务),因此也产生了对计算机硬件资源进行调度的管理需求。

为了实现上述目标,人们在机器资源之上建立了操作系统这个层次(可以理解成服务于各位程序员的系统管家):
操作系统把机器资源抽象成进程/文件/输入输出等子系统,并为这些系统封装出易于使用的接口(C headers)供上层使用,如果把操作系统和用户程序放在一个层次上思考,也可以认为操作系统是一个巨大的公用lib库。
无论如何,从此之后我们实现的应用程序只需要和操作系统交互就可以了。

由于这位管家(操作系统)本身也是程序,用什么语言和它沟通(实现)便成为了很关键的问题。C语言最终经受住了考验,成为最受欢迎的系统语言。我认为这主要归功于其通过变量很直接地内建了内存模型并提供指针这个概念,方便大家通过内存地址进行底层操作。

有请翻译登场

现在我们有了管家为我们提供各种服务(人家说的可是C语言),这下我们可以不再直接使用鸟语(机器语言)和机器叽里哇啦了,改用表达力更强的C语言也是顺理成章的事。可是等等,机器可听不懂C语言啊,怎么办,只能找个翻译(complier/interpreter)了!

这里的翻译特指为了执行而进行的语言转换:将一种语言(源语言,一般是高级语言)翻译成另一种语言(目标语言,一般是低级语言)的过程。

讨论:关于C语言的可移植性

人们经常会提到C语言具有相对方便的可移植性,这里有必要说明“相对”是什么意思。
由于C语言提供一系列C标准库(由类似于C99的一系列标准定义),在不同的系统上,这部分标准库的实现基本都包含在该平台的编译器套件中,所以如果你用C实现的应用程序只包含C标准库中的函数调用,跨平台使用是不成问题的。但是,如果你使用了某些操作系统特定的接口(例如类unix下的pthread等),能否直接移植取决于你的代码是否包含类似

1
2
3
4
5
6
7
8
9
10
11
#if defined(MS_WIN32) && !defined(MS_WIN64) && defined(_M_IX86) && defined(_MSC_VER)
#include "platform/switch_x86_msvc.h" /* MS Visual Studio on X86 */
#elif defined(MS_WIN64) && defined(_M_X64) && defined(_MSC_VER)
#include "platform/switch_x64_msvc.h" /* MS Visual Studio on X64 */
#elif defined(__GNUC__) && defined(__amd64__) && defined(__ILP32__)
#include "platform/switch_x32_unix.h" /* gcc on amd64 with x32 ABI */
#elif defined(__GNUC__) && defined(__amd64__)
#include "platform/switch_amd64_unix.h" /* gcc on amd64 */
#elif defined(__GNUC__) && defined(__i386__)
# ...
#endif

的[可移植代码](具体使用了条件编译)。简而言之,要让你写的C代码可移植,程序员需要具有这部分理念并且自己处理额外的工作量。
这和Java/Python等语言提供的写一遍源码(无需思考可移植性),就可以在任何平台运行还是有本质区别的。

阶段三:物理机 vs 虚拟机

既然刚才已经提到Java/Python具备可移植性,读者肯定好奇他们是怎么办到的,答案就是实现虚拟机。在计算机世界中,达成通用性的代价必然是引入额外的适配层,不同的解决方案只不过是适配层所处的位置不一样而已。Java/Python等语言就选择把这层适配封装进了虚拟机运行时环境。

为了更好说明问题,我们在这里先下一个定义:
我们把能够接受某种语言作为输入并执行的环境称为对应的语言计算机。

  • 上文提到的物理机器和机器语言的执行环境,构成了机器语言计算机(物理计算机)
  • 上文提到的C语言编译器,加上操作系统和物理机器一起,构成了C语言计算机
  • Java的编译器,加上语言对应的虚拟机(一般都是解释器)以及操作系统和物理机,构成了Java计算机。虚拟机负责和底层操作系统交互,并且有能力实现自己的一套调度管理机制。
    可以说,虚拟机作为语言和物理机器操作系统的适配层,封装了变化的底层细节,让语言使用者更专注地使用语言提供的特性,从而提高工作效率。

虚拟语言机器
虚拟语言机器

人们发明不同的生产工具是为了因地制宜,更好地提高生产效率。计算机语言也一样:同样一件事我们可以用不同的计算机语言实现,但是在特定的场景下,使用某种为此种场景优化过的语言往往能产生事半功倍的效果。

计算机语言分类指南

既然是工具,当然可以分分类啦,下面介绍几种最常用的分类:

计算方式:命令式语言 vs 函数式语言

变量的值是否能修改

在命令式语言中,计算是通过修改变量的值(即状态)来实现的,因而同一个变量在不同的时间点可以有不同的值。进而,命令式语言中函数调用的仅仅是代码复用,考虑以下代码:

1
2
3
4
import datetime
def imperative_func(a):
b = datetime.datetime.now()
return a+1

这段python代码会将a的值加1后返回,但同时修改了b的值,所以每次执行这个函数会影响到一个和入参a无关的变量b的状态。或者说,这个函数是具有副作用的。

在函数式语言中,计算是通过函数调用来实现的。对于同一个函数,应用相同入参时返回值必须相同。变量被赋值于一次函数调用,且不能被修改。

迭代 vs 递归

迭代和递归是实现循环逻辑的两种方式:

  • 迭代是通过条件跳转(jmpxx)语句来实现,不需要调用子程序
  • 递归是通过不断调用自身的函数调用

由于调用子程序需要设置和回收栈空间,效率上迭代比递归好很多,但是递归的逻辑比迭代更自洽,容易理解。在函数式语言中,由于变量的值不能被修改,只能用递归来实现循环逻辑。

在实际开发过程中,人们发现命令式语言更接近按照步骤描述算法的自然方法,但是副作用的引入提升了维护已有代码的难度。而函数式编程的特性则可以提高代码的健壮性,所以是目前的一种流行趋势。

实现方法:编译型语言 vs 解释型语言 vs 混合型语言

编译型

对于编译型语言来说,编译器(complier)负责把源语言翻译成目标语言。在时间维度上,目标语言的执行和编译是分隔的。在空间维度上,编译和执行一般也处于不同的进程中。

  • 优点:编译器在编译过程能够得到全量信息的支撑(全部源代码+目标机器的特定信息),因此能够做出比较完善的优化,最终保证代码的执行效率。
  • 缺点:每次代码修改都需要进行重编译。

解释型

执行时,源语言在解释器(interpreter)中边解释边执行,所以时间和空间都一致。

  • 优点:修改源代码之后省去编译这一步即可直接执行。
  • 缺点:由于每一句代码都在解释器中实时翻译,需要占用执行时间。另外,解释器在解释时只能获取该语句之前的信息,很多简单的优化无法进行(例如丢弃没有被使用的变量等)。

混合型

在编译和解释之外,还存在混合型语言,例如Java。混合型语言同时具备编译和解释的过程,源代码首先被编译成中间代码(IL),然后由解释器解释执行。这样代码就具备以下优点:

  • 具有编译型语言提供全量信息的能力,编译过程中可以进行足够的优化
  • 源代码首先被编译成中间代码使得语言具有强可移植性,和机器绑定的信息被置于不同平台的解释器(虚拟机)中实现,编码时无需关注跨平台信息。

关于JIT(Just-In-Time)

再混合型的基础之上,人们又发展出JIT的实现方式,即将混合型语言产生的中间代码直接编译成机器码执行,进一步提高混合型语言的执行效率。

类型检查:静态类型语言 vs 动态类型语言

静态类型

使用静态类型的语言在编译阶段即检查每个变量的类型是否满足操作的要求(类型检查),如果发现问题即抛出编译错误,能够较早地发现类型错误。

动态类型

使用动态类型的语言把变量类型检查放在解释器运行代码时执行,这样做一般是为了支持动态修改变量的类型,以期达到极大的灵活性。代价则是放弃了编译阶段进行类型检查的能力。

类型转换:强类型语言 vs 弱类型语言

强类型语言不许进行隐式的类型转换,而弱类型语言可以。

灵活性:静态语言 vs 动态语言

能够支持在运行时查看修改上下文中对象的元数据(类型/继承关系等)的语言,即动态语言。一般动态语言都需要虚拟机通过解释的方式提供支持,并典型地提供eval函数将输入的字符串当作代码执行。反之则为静态语言。

如何评估计算机语言

鉴于计算机语言是一种工具,评估计算机语言即评估这个语言能带来的便利性。为了抛砖引玉,我先将编程概念一书中提出的评估标准通过一张图展示在下面:

编程语言评估
编程语言评估

引用

  1. greenlet中的可移植代码
  2. 编程语言概念