Python 既是解释型语言,也是编译型语言

哈喽大家好,我是咸鱼

不知道有没有小伙伴跟我一样,刚开始学习 Python 的时候都听说过 Python 是一种解释型语言,因为它在运行的时候会逐行解释并执行,而 C++ 这种是编译型语言

不过我今天看到了一篇文章,作者提出 Python 其实也有编译的过程,解释器会先编译再执行

不但如此,作者还认为【解释】与【编译】是错误的二分法、限制了编程语言的可能性。Python 既是解释型语言,也是编译型语言!

本文文字干货较多,耐心看完相信你会有不小的收获

原文:https://eddieantonio.ca/blog/2023/10/25/python-is-a-compiled-language/

前言

本文所说的 Python ,不是指 PyPy、Mypyc、Numba、Cinder 等 Python 的替代版本,也不是像 Cython、Codon、mojo1这样的类 Python 编程语言

我指的是常规的 Python------CPython

目前,我正在编写一份教材,教学生如何阅读和理解程序报错信息(programming error messages)。我们正在为三种编程语言(C、Python、Java)开设课程

程序报错信息的本质的关键点之一在于程序报错是在不同阶段生成的,有些是在编译时生成,有些是在运行时生成

第一门课是针对 C 语言的,具体来说是如何使用 GCC 编译器,以及演示 GCC 如何将代码转换成可执行程序

  • 预处理(preprocessing)
  • 词汇分析(lexical analysis)
  • 语法分析(syntactic analysis)
  • 语义分析(semantic analysis)
  • 链接(linking)

除此之外,这节课还讨论了在上述阶段可能出现的程序报错,以及这些报错将如何影响所呈现的错误消息。重要的是:早期阶段的错误将阻止在后期阶段检测到错误(也就是说 A 阶段的报错出现之后,B 阶段就算有错误也不会检测出来)

当我将这门课调整成针对 Java 和 Python 时,我发现 Python 和 Java 都没有预处理器(preprocessor),并且 Python 和 Java 的链接(linking)不是同一个概念

我忽略了上面这些变化,但是我偶然发现了一个有趣的现象:

编译器在各个阶段会生成报错信息,而且编译器通常会在继续执行之前把前面阶段的报错显示出来,这就意味着我们可以通过在程序中故意创建错误来发现编译器的各个阶段

所以让我们玩一个小游戏来发现 Python 解释器的各个阶段

Which Is The First Error

我们将创建一个包含多个 bug 的 Python 程序,每个 bug 都试图引发不同类型的报错信息

我们知道常规的 Python 每次运行只会报告一个错误,所以这个游戏就是------哪条报错会被首先触发

python 复制代码
# 下面是有 bug 的程序
1 / 0
print() = None
if False
    ñ = "hello

每行代码都会产生不同的报错:

  • 1 / 0 将生成 ZeroDivisionError: division by zero
  • print() = None 将生成 SyntaxError: cannot assign to function call
  • if False 将生成 SyntaxError: expected ':' .
  • ñ = "hello 将生成 SyntaxError: EOL while scanning string literal .

问题在于,哪个错误会先被显示出来?需要注意的是:Python 版本很重要(比我想象的要重要),所以如果你看到不同的结果,请记住这一点

PS:下面运行代码所使用的 Python 版本为 Python 3.12

在开始执行代码之前,先想想【解释】语言和【编译】语言对你来说意味着什么?

下面我将给出一段苏格拉底式的对话,希望你能反思一下其中的区别

苏格拉底:编译语言是指代码在运行之前首先通过编译器的语言。一个例子是 C 编程语言。要运行 C 代码,首先必须运行像 or clang 这样的 gcc 编译器,然后才能运行代码。编译后的语言被转换为机器代码,即 CPU 可以理解的 1 和 0

柏拉图:等等,Java不是一种编译语言吗?

苏格拉底:是的,Java是一种编译语言。

柏拉图:但是常规 Java编译器的输出不是一个 .class 文件。那是字节码,不是吗?

苏格拉底:没错。字节码不是机器码,但 Java 仍然是一种编译语言。这是因为编译器可以捕获许多问题,因此您需要在程序开始运行之前更正错误。

柏拉图:解释型语言呢?

苏格拉底:解释型语言是依赖于一个单独的程序(恰如其分地称为解释器)来实际运行代码的语言。解释型语言不需要程序员先运行编译器。因此,在程序运行时,您犯的任何错误都会被捕获。Python 是一种解释型语言,没有单独的编译器,您犯的所有错误都会在运行时捕获。

柏拉图:如果 Python不是一种编译语言,那么为什么标准库包含名为 py_compile and compileall 的模块?

苏格拉底:嗯,这些模块只是将 Python转换为字节码。他们不会将 Python 转换为机器代码,因此 Python 仍然是一种解释型语言。

柏拉图:那么,Python和 Java都转换为字节码了吗?

苏格拉底:对。

柏拉图:那么,为什么Python是一种解释型语言,而 Java却是一种编译型语言呢?

苏格拉底:因为 Python 中的所有错误都是在运行时捕获的。 (ps:请注意这句话)

  • 回合一

当我们执行上面那段有 bug 的程序时,将会收到下面的错误

python 复制代码
  File "/private/tmp/round_1.py", line 4
    ñ = "hello  # SyntaxError: EOL while scanning string literal
        ^
SyntaxError: unterminated string literal (detected at line 4)

检测到的第一个报错位于源码的最后一行。可以看到:在运行第一行代码之前,Python 必须读取整个源码文件

如果你脑子里有一个关于【解释型语言】的定义,其中包括"解释型语言按顺序读取代码,一次运行一行",我希望你忘掉它

我还没有深入研究 CPython 解释器的源码来验证这一点,但我认为这是第一个检测到的报错的原因是 Python 3.12 所做的第一个步骤是扫描(scanning ),也称为词法分析

扫描器将整个文件转换为一系列标记(token),然后继续进行下一阶段。

扫描器扫描到源码最后一行的字符串字面值末尾少了个引号,它希望把整个字符串字面值转换成一个 token ,但是没有结束引号它就转换不了

在 Python 3.12 中,扫描器首先运行,所以这也是为什么第一个报错是unterminated string literal

  • 回合二

我们把第四行的代码的 bug 修复好,第 1 2 3 行仍有 bug

python 复制代码
1 / 0
print() = None
if False
    ñ = "hello"

我们现在来执行代码,看下哪个是第一个报错

python 复制代码
File "/private/tmp/round_2.py", line 2
    print() = None
    ^^^^^^^
SyntaxError: cannot assign to function call here. Maybe you meant '==' instead of '='?"

这次是第二行报错!同样,我没有去查看 CPython 的源码,但是我有理由确定扫描的下一阶段是解析(parsing),也称为语法分析

在运行代码之前会先解析源码,这意味着 Python 不会看到第一行的错误,而是在第二行报错

我要指出我为这个小游戏而编写的代码是完全没有意义的,并且对于如何修复 bug 也没有正确的答案。我的目的纯粹是估计编写错误然后发现 python 解释器现在处在哪个阶段

我不知道 print() = None可能是什么意思,所以我将通过将其替换为print(None)来解决这个问题,这也没有意义,但至少它在语法上是正确的。

  • 回合三

我们把第二行的语法错误也修复了,但源码还有另外两个错误,其中一个也是语法错误

python 复制代码
1 / 0
print(None)
if False
    ñ = "hello"

回想一下,语法错误在回合二的时候优先显示了出来,在回合三还会一样吗

python 复制代码
  File "/private/tmp/round_3.py", line 3
    if False
            ^
SyntaxError: expected ':'

没错!第三行的语法错误优先于第一行的错误

正如回合二一样,Python 解释器在运行代码之前会先解析源码,对其进行语法分析

这意味着 Python 不会看到第一行的错误,而是在第三行报错

你可能想知道为什么我在一个文件中插入了两个 SyntaxError,难道一个还不够表明我的观点吗?

这是因为 Python 版本的不同会导致结果的不同,如果你在 Python3.8 或者更早的版本去运行代码,那么结果如下

在 Python 3.8 中,第 2 轮报告的第一个错误消息位于第 3 行:

python 复制代码
1 / 0
print() = None
if False
    ñ = "hello"
  
# 报错
File "round_2.py", line 3
    if False
            ^
SyntaxError: invalid syntax

修复第三行的错误之后,Python 3.8 在第 2 行报告以下错误消息:

python 复制代码
1 / 0
print() = None
if False:
    ñ = "hello"

# 报错
  File "round_3.py", line 2
    print() = None
    ^
SyntaxError: cannot assign to function call

为什么 Python 3.8 和 3.12 报错顺序不一样?是因为 Python 3.9 引入了一个新的解析器。这个解析器比以前的 naïve 解析器功能更强大

旧的解析器无法提前查看多个 token,这意味着旧解析器在技术上可以接受语法无效的 Python 程序

尤其是这种限制导致解析器无法识别赋值语句的左边是否为有效的赋值目标,好比下面这段代码,旧解析器能够接收下面的代码

python 复制代码
[x for x in y] = [1,2,3]

上面这段代码没有任何意义,甚至 Python 语法是不允许这么使用的。为了解决这个问题,Python 曾经存在过一个独立的,hacky 的阶段(这个 hacky 我不知道用什么翻译比较好)

Python会检查所有的赋值语句,并确保赋值号左边实际上是可以被赋值的东西

而这个阶段是发生在解析之后,这也就是为什么旧版本 Python 中会先把第二行的报错先显示出来

  • 回合四

现在还剩最后一个错误了

python 复制代码
1 / 0
print(None)
if False:
    ñ = "hello"

我们来运行一下

python 复制代码
Traceback (most recent call last):
  File "/private/tmp/round_4.py", line 1, in <module>
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero

需要注意的是,Traceback (most recent call last)表示 Python 运行时报错的主要内容,这里在回合四才出现

经过前面的扫描、解析阶段,Python 终于能够运行代码了。但是当 Python 开始运行解释第一行的时候,引发一个名为 ZeroDivisionError 的报错

为什么知道现在处于【运行时】,因为 Python 已经打印出 Traceback (most recent call last),这表示我们有一个堆栈跟踪

堆栈跟踪只能在运行时存在,这意味着这个报错必须在运行时捕获。

但这意味着在回合1~3 中遇到的报错不是运行时报错,那它们是什么报错?

Python 既是一种编译语言,也是一种解释语言

没错!CPython 解释器实际上是一个解释器,但它也是一个编译器

我希望上面的练习已经说明了 Python 在运行第一行代码之前必须经过几个阶段:

  • 扫描(scanning )
  • 解析(parsing )

旧版本的 Python 多了一个额外阶段:

  • 扫描(scanning )
  • 解析(parsing )
  • 检查有效的分配目标(checking for valid assignment targets)

让我们将其与前面编译 C 程序的阶段进行比较:

  • 预处理
  • 词汇分析("扫描"的另一个术语)
  • 语法分析("解析"的另一个术语)
  • 语义分析
  • 链接

Python 在运行任何代码之前仍然执行一些编译阶段,就像 Java一样,它会把源码编译成字节码

前面三个报错是 Python 在编译阶段产生的,只有最后一个才是在运行时产生,即ZeroDivisionError: division by zero.

实际上,我们可以使用命令行上的 compileall 模块预先编译所有 Python 代码:

python 复制代码
$ python3 -m compileall

这会将当前目录中所有 Python 文件的编译字节码放入其中 __pycache__/ ,并显示任何编译器错误

如果你想知道那个 __pycache__/ 文件夹中到底有什么,我为 EdmontonPy 做了一个演讲,你应该看看!

演讲地址:https://www.youtube.com/watch?v=5yqUTJuFuUk&t=7m11s

只有在 Python 被编译为字节码之后,解释器才会真正启动,我希望前面的练习已经证明 Python 确实可以在运行时之前报错

编译语言与解释语言是错误的二分法

每当一种编程语言被归类为【编译】或【解释】语言时,我都会感到很讨厌。一种语言本身不是编译或解释的

一种语言是编译还是解释(或两者兼而有之!)是一个实现细节

我不是唯一一个有这种想法的人。Laurie Tratt 有一篇精彩的文章,通过编写一个逐渐成为优化编译器的解释器来论证这一点

文章地址:https://tratt.net/laurie/blog/2023/compiled_and_interpreted_languages_two_ways_of_saying_tomato.html

还有一篇文章就是 Bob Nystrom 的 Crafting Interpreters。以下是第 2 章的一些引述:

编译器和解释器有什么区别?

事实证明,这就像问水果和蔬菜之间的区别一样。这似乎是一个二元的非此即彼的选择,但实际上"水果"是一个植物学术语,而"蔬菜"是烹饪学术语。

严格来说,一个并不意味着对另一个的否定。有些水果不是蔬菜(苹果),有些蔬菜不是水果(胡萝卜),但也有既是水果又是蔬菜的可食用植物,如西红柿

当你使用 CPython 来运行 Python 程序时,源码会被解析并转换成内部字节码格式,然后在虚拟机中执行

从用户的角度来看,这显然是一个解释器(因为它们从源码运行程序),但如果你仔细观察 CPython(Python 也可译作蟒蛇)的鳞状表皮(scaly skin),你会发现它肯定在进行编译

答案是:CPython 是一个解释器,它有一个编译器

那么为什么这很重要呢?为什么在【编译】和【解释】语言之间做出严格的区分会适得其反?

【编译】与【解释】限制了我们认为编程语言的可能性

编程语言不必由它是编译还是解释来定义的!以这种僵化的方式思考限制了我们认为给定的编程语言可以做的事情

例如,JavaScript 通常被归入"解释型语言"类别。但有一段时间,在 Google Chrome 中运行的 JavaScript 永远不会被解释------相反,JavaScript 被直接编译为机器代码!因此,JavaScript 可以跟上 C++ 的步伐

出于这个原因,我真的厌倦了那些说解释型语言必然慢的论点------性能是多方面的,并且不仅仅取决于"默认"编程语言的实现

JavaScript 现在很快了、Ruby 现在很快了、Lua 已经快了一段时间了

那对于通常被标记为编译型语言的编程语言呢?(例如 C)你是不会去想着解释 C 语言程序的

语言之间真正的区别

语言之间真正的区别:【静态】还是【动态】

我们应该教给学生的真正区别是语言特性的区别,前者可以静态地确定,即只盯着代码而不运行代码,后者只能在运行时动态地知道

需要注意的是,我说的是【语言特性】而不是【语言】,每种编程语言都选择自己的一组属性,这些属性可以静态地或动态地确定,并结合在一起,这使得语言更"动态"或更"静态"

静态与动态是一个范围,Python 位于范围中更动态的一端。像 Java 这样的语言比 Python 有更多的静态特性,但即使是 Java 也包括反射之类的东西,这无疑是一种动态特性

我发现动态与静态经常被混为一谈,编译与解释混为一谈,这是可以理解的

因为通常使用解释器的语言具有更多的动态特性,如 Python、Ruby 和 JavaScript

具有更多静态特性的语言往往在没有解释器的情况下实现,例如 C++ 和 Rust

然后是介于两者之间的 Java

Python 中的静态类型注释已经逐渐(呵呵)在代码库中得到采用,其中一个期望是:由于更多静态的东西,这可以解锁 Python 代码中的性能优势

不幸的是,事实证明,Python 中的类型(是的,只是一般类型,考虑元类)和注释本身都是Python 的动态特性,这使得静态类型不是大伙所期望的性能优势

最后总结一下:

  • CPython 是一个解释器,它有一个编译器(或者说 Python 既是解释型语言,也是编译型语言)
  • Python 是编译的还是解释的并不重要。重要的是,相对于那些具有更多静态属性(在编译或解释阶段可以在运行前确定的属性)的编程语言,Python 中可以在运行前确定的属性相对较少,这意味着在 Python 中,许多属性是在运行时动态确定的,而不是在编译或解释时静态确定的
  • 由于 Python 具有较少的静态属性,这意味着在运行时,某些错误可能只能在运行时才会显现,而不是在编译或解释时就能被发现
  • 因为在具有更多静态属性的编程语言中,许多错误会在编译或解释时被捕获,因此更容易在编码阶段就发现和修复
  • 这是真正重要的区别,这是一个比【编译】和【解释】更细致、更微妙的区别。出于这个原因,我认为强调特定的静态和动态特性是很重要的,而不是一昧的局限于"解释型"和"编译型"语言之间的繁琐的区别。