跳转至

编程语言:高级概述

在这一部分中,我们将讨论编程语言的目的以及它们的通用分类。

什么是编程语言?为什么需要它们?

一般来说,编程语言是计算机的抽象,旨在使设计、表示、构建和理解计算机程序对人类更容易

注意,编程语言有时以更宽松的方式定义,包括汇编语言在内; 然而,在本模块中,我们将"编程语言"定义为仅包括现代的、"面向人类"的编程语言, 如 C/C++、Python、Rust、Java、JavaScript 等; 我们不包括不适用于人类使用的语言, 如汇编语言和二进制机器码。

回顾一下前一模块,计算机的最低层(最接近硬件)的抽象是指令集体系结构 (Instruction Set Architecture,ISA), 但使用 ISA 进行编程对人类来说非常不直观。 因此,人们决定构建更高级的易于理解和使用的抽象层次; 这正是编程语言的用途。

由于编程语言是为了方便人类而设计的高级抽象, 因此它们的编程模型通常距离实际的计算机硬件(与 ISA 相比)远得多。 例如,寄存器是几乎每个 ISA 中都可以找到的硬件细节,在几乎每个编程语言中都被抽象化掉。

关于寄存器

在现代 CPU 中,ISA 中定义的寄存器通常不是实际的硬件组件, 但它们也不仅仅是抽象。

CPU 中有"物理寄存器"。 然而,它们不是直接映射到 ISA 定义的"体系结构寄存器"。 相反,通常情况下,物理寄存器要比体系结构寄存器多得多; 后者通过一种称为"寄存器重命名"的技术,在运行时以多对多的方式映射到前者。

编程语言中常见的一些概念如下,

与其在硬件上的"较低级别实现"(仍然是对硬件的抽象)相比:

编程语言概念 ISA 实现 描述
变量/常量 保存数据的内存块 像字符串 "Hello, World!"、数字 1.22 或数据树这样的"东西"
函数 通常是保存 ISA 指令的内存块(在编译语言中) 能够执行某个特定任务的工具
结构体 在编译语言中不会存在于运行时 结构化数据的定义,如 "学生结构体包含姓名、年龄和分数"

编程语言的通用分类

目前存在大量的编程语言; 每种编程语言都有自己的特点、抽象和权衡,适用于特定的用例集; 但是它们之间有一些共同之处, 从多个角度来看,编程语言可以一般地划分为多个大类。

在本小节中,我们将讨论一些常见的分类编程语言的方法, 以及每个类别中语言的一般特征。

术语 "权衡"

"权衡"是软件和硬件开发中的一个基本概念。 一般来说,它表示在一方面"交换"(牺牲)质量以获得另一方面的质量

例如,许多编程语言(如 Python)为了使用的简单性而牺牲了速度和硬件效率; 另一方面,C/C++ 等语言则为了对硬件的控制以及更快的执行而牺牲了使用的简单性和学习成本。

执行模型:编译型 vs 解释型

一种常见的划分编程语言的方式是通过执行模型, 它可以是"编译型"、"解释型",或两者的混合。

编译型语言

回顾一下前一部分, 几乎每个计算机程序的运行时(即程序运行时的时间) 都可以归结为在硬件上执行由 ISA 定义的指令。

然而,编程语言是为了人类而设计的,而不是计算机硬件; 因此必须有一种方式将用编程语言编写的软件转换为可在硬件上运行的机器可执行代码, 要么在程序运行之前,要么在程序运行期间。

编译型语言选择前者。 在编译型语言中,开发人员在编程语言中编写的代码(以形成完整程序或部分程序) 通过一个称为"编译器"的软件将其转换为机器可执行代码, 然后该以机器代码形式的应用程序可以直接在硬件上运行。

graph TD

    subgraph 开发人员端
        S([源代码]) -->|编译| M([机器码])
    end

    subgraph 用户端
        M -->|在| H([硬件]) 上执行
    end

与编译型语言相关的一些术语:

  • 源代码:开发人员用编程语言编写的代码。
  • 机器码:可直接在硬件上运行的代码。
  • 编译:将源代码转换为机器码的过程。
  • 已编译应用程序:已经编译的软件(从源代码),通常以机器码形式,可以直接在硬件上运行。
  • 二进制可执行文件:由于机器码通常以二进制形式(例如,001100101010111000110)存在, 可以直接在硬件上运行的已编译应用程序通常称为"二进制可执行文件"。
  • 编译:"编译"的动词形式。
  • 编译器:执行编译的软件。
  • 编译时:进行编译的时间。
  • 运行时:运行程序的时间。

反汇编

你可能想知道是否可能将机器码转换回源代码。 是的,确实可以。 这种过程被称为反汇编, 有很多反汇编器能够执行反汇编。

编译型语言的优点有:

  • 快速和硬件效率高: 编译型语言的最大优点是它们快速和硬件效率高。 通过编译的过程,确保了"编译型应用程序中的每一条指令都以最高效的方式执行有用的工作"。 许多人对现代编译器能够做到的大量优化还感到惊奇, 从识别和消除源代码中的无用代码,到以更好地与硬件配合运行的方式"重写"源代码。 此外,由于已编译的应用程序与硬件直接进行交互, 它们运行速度更快,使用的内存更少。
  • (有时)更好的错误排除: 在许多情况下(例如变量类型,稍后我们将介绍), 编译型语言的静态特性意味着源代码中的许多错误可以在编译时检测出来, 而不会导致用户在运行时遇到故障。

缺点有:

  • (通常)学习和使用上较困难: 由于编译型语言通常更接近硬件, 需要更多的努力来学习它们。 使用它们通常也更困难, 因为与硬件接近意味着你必须自己管理很多事情(特别是内存), 而不是将该责任委托给一个软件(大多数解释型语言如此)。 此外,在编译语言中通常需要更多的代码来编写同样的应用程序。
  • 灵活性较差: 编译型语言的静态特性意味着它们在某些方面更难以做某些事情, 例如在运行时修改变量类型。
  • 与硬件相关:由于机器码是与硬件最接近的抽象, 而且不同操作系统通常具有不同的二进制可执行文件格式, 已编译的应用程序通常无法在多个操作系统上运行, 或者在实现不同 ISA 的硬件上运行。 例如,为 Windows 编译的应用程序无法在 Linux 或 macOS 上运行; 针对 x86 ISA 的 PC 应用程序无法在通常使用 ARM ISA 的智能手机上运行。

一些流行的编译型语言:

  • C/C++:虽然创作于很多年前,C/C++ 仍然是世界上最流行的编译型语言之一。 它提供对硬件的最高控制和无与伦比的性能(如果以正确的方式使用)。 然而,它的学习曲线较陡峭, 内存管理以难以置信的难度而闻名 (可以通过使用诸如智能指针和 RAII 等工具来缓解)。
  • Rust:一种现代的、相对较新的编程语言。 它是世界上最难学习的语言之一, 但一旦你熟悉它,你将从其内存安全保证中受益很多, 以及现代的语法和设计使其非常直观和简洁。

解释型语言

与编译型语言不同, 解释型语言选择使用一种特殊的软件(称为"解释器")在运行时实际地"执行"源代码。 解释器会读取源代码中的每个"高级"指令并执行它。


graph TD

    subgraph 开发人员端
        S([源代码])
    end

    subgraph 用户端
        S -->|由| I([解释器]) 解释 -->|在| H([硬件]) 上运行
    end

从一方面来看,你可以认为源代码由一个理解源代码的"虚拟硬件"(解释器)实际地"执行"; 但从另一方面来看,由于解释器的执行最终归结为机器码, 你可以认为源代码在运行时由解释器动态地转换为机器码。

例如,考虑下面的 Python 代码:

a = 125
b = 137
c = a * b
print(f"The result of {a} times {b} is {c}")

即使你对编程没有经验, 上面的代码应该很容易理解: 它将 125 和 137 相乘,然后将结果打印到控制台 (即执行代码的终端)。

该代码如何由解释器执行? 解释程序实际上会读取并执行每一行:

  1. 第一行 a = 125 通过分配一块内存,将其命名为 a 并写入 125, 被执行。
  2. 执行第二行 b = 137 时发生类似的事情。
  3. 执行第三行 c = a * b 时,读取以前命名为 ab 的内存块, 将它们相乘并将结果写入已分配的命名为 c 的内存块。
  4. 执行第四行 print(f"The result of {a} times {b} is {c}") 时, 读取 abc,然后将它们打印到终端上。

注意

上述解释在硬件上实际发生的情况并不完全正确。 Python 代码的实际执行是一个复杂的过程, 考虑到解释器如何"理解"源代码, 以及 Python 解释器的执行引擎,即 Python 虚拟机, 是一种基于堆栈的系统。

但是现在,不要过多地担心硬件细节。 上面的解释提供了对解释型语言提供的抽象的很好说明。

解释型语言的优点有:

  • 它们的动态特性允许更灵活地编写代码。 例如,Python 允许您在运行时更改变量的类型(例如,从整数更改为字符串), 而这在编译型语言中通常是禁止的。
  • 通常更易学和使用。 解释器提供了一种更人性化的计算机抽象,这使得编程更加容易。 在解释型语言中,通常无需手动管理内存; 解释器会为您处理这些。
  • 更好的跨平台和跨 ISA 兼容性: 由于解释器为计算机硬件提供了另一层抽象, 使用解释型语言编写的软件可以在安装了该语言解释器的任何计算机上运行, 而不管其 ISA 和操作系统如何。

缺点有:

  • 缓慢和硬件效率低。 显然,与直接执行已编译机器码相比, 解释器读取、解析和"模拟执行"源代码的每一行要慢得多。 事实上,Python 代码可以比等效的 C++ 代码慢 20 到 200 倍。
  • (有时)更难调试: 解释型语言的灵活性也意味着它们有时更难调试。 在极端情况下,解释型语言中的所有错误都是运行时错误, 因为不对源代码进行编译时分析。 如果应用程序中存在一个每 1000 次发生一次的错误, 在开发应用程序时很有可能不会察觉到它, 但是当一个倒霉的用户打开它时会产生一个运行时错误。

混合技术

除了上述介绍的"纯"编译型/解释型语言之外, 还有一些模糊了它们之间界限的技术:

即时(Just-In-Time,JIT)编译

即时编译技术允许在运行时编译源代码的某些部分。

JIT 编译提供了一些优势:

  • 程序可以在安装了 JIT 编译器的任何计算机上运行。
  • JIT 通常比解释型语言更快,因为 JIT 编译器将源代码编译为机器码。
  • 与编译型语言的"静态编译器"相比,JIT 编译器有时可以找到更多有效的优化, 因为它可以访问程序的运行时状态。
中间语言(Intermediate Language,IL)和虚拟机(Virtual Machine,VM)

一些语言支持将源代码编译为既非人为代码也非硬件可执行代码的中间表示。 在运行时,这种中间表示由一段称为"虚拟机"的软件执行 (与能够安装操作系统的虚拟机不要混淆)。 其中最著名的例子是 Java 及其字节码表示。

这种技术在速度和跨平台兼容性方面提供了一种权衡: 由于源代码被编译为中间表示, 编译器可以做一些编译时优化; 而虚拟机的引入使得中间表示 可以在安装了虚拟机的任何计算机上执行。

重要的是要注意,中间表示不是设计为能够被人类阅读的, 因为它预计由编译器生成,而不是由人类开发者编写。

编程范式:有状态与无状态

另一种根据它们的编程范式来划分编程语言的方式。 从根本上讲,编程范式是一种设计和理解计算机程序的心智模型, 你可以将其视为计算机的抽象。 大多数编程语言允许使用不同的编程范式来构建软件, 但其中一些范式是更常用的。

一般来说,有两个主要的编程范式:有状态和无状态。

有状态编程范式和面向对象编程(OOP)

有状态编程范式采用状态的理念, 并且通常等同于现代的"面向对象编程"。

状态是在程序执行期间可以变化并影响其行为的事物。 通常,这些状态保存在为程序分配的内存块中。 值得注意的是,在有状态编程范式中, 程序的行为不仅取决于输入,还取决于状态; 即使输入相同,程序的输出也可以不同。

例如,考虑一个包含按钮和保存数字的简单应用程序。 数字从 0 开始;每次按下按钮,数字加 1, 并显示在屏幕上。 在这种情况下,"数字"是程序的状态, 因为它在按下按钮时会发生变化,并影响在屏幕上显示的内容。

以下图示说明了有状态编程范式的编程模型:

graph TD

    I([输入]) -->|改变| P([程序状态])
    I -->|影响| B([程序行为])
    P -->|影响| B

无状态编程范式和函数式编程

无状态编程范式通常倾向于将计算机程序抽象为函数(数学意义上), 这些函数将输入转换为输出,且没有副作用; 通常不鼓励或禁止使用状态。 无状态编程范式通常称为函数式编程

例如,考虑计算两个人的年龄差异的程序。 在函数式编程范式中,这样的过程可以抽象为以下图示:

flowchart LR

    PA([PersonA])
    PB([PersonB])

    PAA([PersonA的年龄])
    PBA([PersonB的年龄])

    O([输出])

    PA --> GA[得到年龄] --> PAA
    PB --> GA --> PBA
    PAA --> CAD[计算年龄差异]
    PBA --> CAD
    CAD --> O

正如你所看到的,处理的每个步骤(无论是 得到年龄 还是 计算年龄差异) 的唯一任务是将其输入转换为其输出; 没有副作用(例如,向屏幕打印内容或更改一块内存的内容), 而且相同的输入总是产生相同的输出。 除了程序输入之外,没有任何状态影响程序的行为。

编程范式和编程语言

不同的编程范式具有不同的优点和缺点。 例如,函数式编程使开发人员更容易理解程序的行为 (有状态程序的行为通常更难以预测,特别是在并发情况下, 因为它依赖于输入和状态), 而具有有状态编程范式的语言在功能上更强大。

因此,大多数软件应用程序在不同方面使用不同的编程范式, 而不是完全使用一种范式。 一个常见的做法是将程序的高级结构设计为有状态对象 (即具有状态的东西), 然后在实现这些对象的功能时使用函数式编程。

同样,大多数现代编程语言都支持多种范式。 然而,其中一些语言可能通过设计鼓励一种范式或另一种范式。

例如,支持有状态范式/面向对象编程的一些编程语言有:

  • Java:Java 是为面向对象编程(OOP)而设计的最流行的语言之一。
  • C/C++:C/C++ 是一种多范式语言,具备优秀的面向对象编程支持。
  • Python:Python 也是一种多范式语言。
  • Rust:Rust 是一种多范式语言,主要鼓励函数式编程, 但也支持面向对象编程。

一些支持无状态范式/函数式编程的编程语言有:

  • LISP:LISP 是最古老的编程语言之一。 它具有独特的设计,主要支持现代中所谓的函数式编程。
  • Rust:上面已经提到过,Rust 是一种主要鼓励函数式编程的多范式语言。
  • Python:上面已经提到过,Python 也是一种多范式语言,没有明显倾向于函数式编程或面向对象编程。

通用目的语言 vs 领域专用语言(DSLs)

第三种划分编程语言的方法是根据其适用领域。 可以将编程语言分为两大类别: 通用目的语言领域专用语言(Domain Specific Languages,DSLs)

通用目的语言

通用目的语言是为通用用途而设计的编程语言。 也就是说,你可以使用它们来为任何任务构建软件。 我们之前提到的大多数语言都属于这个类别, 包括 C/C++、Python、Rust 和 Java。

领域专用语言

领域专用语言 (Domain Specific Languages, DSLs) 是为特定领域而设计的语言, 例如数学、数据库操作、数据分析和人工智能。 严格来说,许多 DSLs 不符合本节介绍的编程语言的定义, 因为它们并不旨在用于构建软件; 然而,如果你将编程语言的定义推广为 "一种使特定任务更容易完成的抽象或模型", 那么 DSLs 的确符合这一定义。

一些 DSLs 的例子:

  • SQL:SQL 是一种为数据库操作而设计的 DSL。
  • Mermaid:Mermaid 是一种 DSL,允许您以文本形式高效地表示图形、流程图、思维导图等。
  • Slint:Slint 是一个 UI(用户界面,如按钮、窗口和滑块)框架,提供了表示 UI 元素的 DSL。
  • Bash:Bash 可以被看作是一种使在终端上做事情更容易的 DSL。
  • MATLAB:MATLAB 是一种流行的数学 DSL,支持各种数学概念和运算,如矩阵和矩阵求逆。

有趣的事实(个人主观观点):开发者的评论

就我个人而言,我觉得 MATLAB 能够保持为最受欢迎的语言之一是个奇迹, 考虑到它丑陋的集成开发环境、古老的语法和人性化设计的否定 (比如使用花括号从 1 开始索引数组,而其他人都使用 0 和方括号)。

尽管 MATLAB 是我认真学习的第一种语言, 但它现在是我最不喜欢和最少使用的语言。 我强烈不建议任何新的开发者使用 MATLAB 作为他/她的主要语言, 并强烈鼓励当前的 MATLAB 用户切换到 Python, 这对他们自己和开源社区的发展都有很大帮助。

结论

在本节中,我们讨论了编程语言是什么以及它们的通用分类。

一般来说,编程语言是计算机的抽象,旨在使设计、表示、构建和理解计算机程序对人类更容易

编程语言可以根据以下三种方式进行分类:

  1. 执行模型:
    • 编译型语言:在程序执行之前将源代码编译为机器码。
    • 解释型语言:解释器直接执行源代码。
    • 混合化技术:即时编译 (JIT) 与中间语言 (IL) 和虚拟机 (VM)。
  2. 编程范式:
    • 有状态编程范式和面向对象编程 (OOP):程序包含改变其行为的状态,除了输入外还依赖状态。
    • 无状态编程范式和函数式编程:程序被抽象为将输入转换为输出的函数,并且没有副作用;不使用状态或不鼓励使用状态。
  3. 适用领域:
    • 通用目的语言:可以用于任何用途构建软件的编程语言。
    • 领域专用语言 (DSLs):为特定领域设计的编程语言。

恭喜你!你现在对编程语言有了高层次的了解。 接下来,我们将介绍几乎每个现代通用语言都会遇到的常见抽象和思想。