Python 是一种 “编译型语言”

2023年 11月 15日 71.7k 0

导读:学习使用了多年的Python,其实它是一种编译型语言?

这篇文章的目标是告诉各位同学,我们用的 Python 本质是一种“编译型语言”。

我这里所说的“Python”并不是指 PyPy、Mypyc、Numba、Cinder等 Python 的替代版本,也不是Cython、Codon、Mojo 1等“类 Python ”的语言。

这里指的是正常的 Python,使用CPython纯官方编译器的内核的“正版”。

可能你的电脑上已经安装了 Python。当你用搜索引擎搜索“python”并下载第一个出现的东西时,这就是属于你的Python。你只需在新的命令行中输入即可启动 Python 。

对,那并不是一条蟒蛇,而是一门编程语言,而 Python 确确实实还是一种“编译型编程语言”。

一些前置的技术背景

目前,我正在编写教学生如何阅读和理解编程错误消息的课程材料。

我正在为3种编程语言创建课程:C、Python 和 Java。不管是什么语言,教授错误消息这个地方时,其本质的关键点之一是它们是在不同阶段生成的 :一些错误消息在编译时报告,而另一些错误消息在程序主动运行时报告。

第一课我是为 C 语言编写的,特别是使用 GCC 编译器。这些经验教训表明,GCC 将把代码转变为正在运行的程序的任务分为以下不同的阶段:

  • 预处理

  • 词法分析

  • 句法分析

  • 语义分析

  • LINK链接

  • 此外,课程会和同学们讨论每个阶段可能发生的错误,以及这些错误将如何影响所显示的错误消息。重要的是,早期阶段的错误将阻止后面阶段检测的错误。

    当我针对 Java 和 Python 改编本课程时,我意识到有些事情必须改变。例如,Python 或 Java 都没有预处理器,并且“链接”在 Python 或 Java 中实际上并不是同一件事,所以我跳过了这些主题。

    但是,我偶然间发现了一个有趣的总结。

    错误消息可以帮助我们发现编译的不同阶段

    事实上,错误消息是由编译器的不同阶段生成的,并且编译器通常在继续运行之前从早期阶段发出错误,这也表示我们可以通过在程序中故意创建错误来发现编译器的阶段。

    让我们通过玩一个我喜欢称为小游戏的来探索 Python 解释器的各个阶段......

    哪个是这个第一错误!

    我们将创建一个带有多个错误的Python 程序,每个错误都会尝试引发不同类型的错误消息。我们知道常规的Python 一次只报告一条错误消息,所以游戏是先报告哪条错误消息?

    这是有错误的程序:

    1 / 0print() = Noneif 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 3.12 的错误消息。说明:Python 的特定版本很重要(比我想象的更重要),所以如果你看到不同的结果,请先记住这一点。

    第1轮

    现在知道了规则和期望的错误消息,让我们开始!先考虑如果不运行代码,会先报上面哪个错误信息?

    在揭晓答案之前,请先想一下“解释型”语言与“编译型”语言对你意味着什么。这是一段苏格拉底式对话,希望能让大家反思其中的区别:

    苏格拉底:编译语言是代码首先通过编译器才能运行的语言。C 语言就是一个例子。要运行 C 代码,首先必须运行类似gcc或 的 编译器clang,然后最后才能运行代码。编译语言被转换为二进制机器代码——CPU 可以理解的 1 和 0。

    Plato:但是等等,Java 不是一种编译语言吗?

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

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

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

    柏拉图:什么是解释性语言呢?

    苏格拉底:解释性语言是一种依赖于单独的程序(恰当地称应该叫解释器)来实际运行代码的语言。解释型语言不需要完全编译后就运行。因此在程序运行时犯的任何错误都将被捕获。Python 就是一种解释性语言——没有单独的编译器,并且你犯的所有错误都会在运行时捕获。

    Plato:如果 Python 不是编译语言,那么为什么标准库包含名为 py_compile和 的模块compileall?

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

    Plato:那么,Python 和 Java 都会转换为字节码吗?

    苏格拉底:正确。

    Plato:那么为什么 Python 是解释性语言,而 Java 却是编译性语言呢?

    苏格拉底:因为Python中的所有错误都是在运行时捕获的。

    好了,差不多够了。让我们来看看实际的答案。如果在 Python 3.12 中运行上面的代码...

    🥁🥁🥁🥁

    你将收到类似的错误消息:

    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 所做的第一步是扫描(也称为词法分析)分析)。扫描器将整个文件转换为一系列标记,然后继续下一阶段。字符串文字末尾缺少引号是扫描器检测到的错误 - 扫描器想要将整个字符串转换为一个大标记,但在找到结束引号之前它无法做到这一点。在 Python 3.12 中,扫描器先运行,然后再运行,因此这是第一条错误消息。

    让我们关闭第 4 行的引号以消除该错误消息,然后重试一下。

    第二轮

    修复了第一个错误后,我们的代码修改如下所示:

    1 / 0print() = Noneif False    ñ = "hello"
    
    

    第 1、2、3 行仍然有错误。

    哪个!是!这!第一的!错误!

    对第一个报告的错误进行有根据的猜测。这次我就不给你们讲稻草人式的苏格拉底式对话了,直接进入正题。

    🥁🥁🥁🥁

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

    这里发生了什么?

    我没有检查 CPython 的源代码,但我有理由确定下一阶段是解析(也称为语法分析)并且解析器报告源代码中的第一个错误。解析整个文件发生在运行第一行代码之前,这意味着 Python 甚至看不到第 1 行的错误并报告第 2 行的语法错误。

    在这一点上,我现在要指出,我为本练习编写的程序完全是无意义的,并且对于如何修复错误没有正确的答案。我不知道 的意图是什么 print() = None,所以我将通过将其替换为来解决这个问题print(None) ,这也没有具体意义,但至少它在语法上是正确的。

    第三轮

    我们修复了一个语法错误,但文件中还存在另外两个错误,其中一个也是语法错误。我们的文件如下所示:

    1 / 0print(None)if False    ñ = "hello"

    哪个是这第一的错误!?

    回想一下,似乎语法错误在第 2 轮中优先。在第 3 轮中也会一样吗?

    🥁🥁🥁🥁

    File "/private/tmp/round_3.py", line 3    if False            ^SyntaxError: expected ':'

    是的!第 3 行的语法错误优先于第 1 行发生的错误。

    这里发生了什么?

    正如第 2 轮中一样,解析器在进入下一阶段之前首先查看整个文件。正如错误消息(隐式)所指示,修复方法是在行尾之前插入冒号。

    旁白:Python 版本之间的差异

    你可能想知道为什么我在SyntaxError出现时,在同一个文件中插入两个 s 。难道这还不足以表明我的观点吗?

    这就是Python 的特定版本变得相关的地方。如果你在 Python 3.8 或更早版本中尝试相同的练习,第 2 轮和第 3 轮的行号会交换。

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

    File "round_2.py", line 3    if False            ^SyntaxError: invalid syntax
    
    

    插入缺少的冒号后,Python 3.8 在第 2 行报告以下错误消息:

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

    为什么 Python 3.8 和 3.12 报告不同的错误?

    原因是Python 3.9引入了新的语言解析器。该解析器比之前相对简单的解析器功能更强大。旧的解析器(用于解析书呆子的 LL(1))无法提前查找多个标记,这意味着解析器本身在技术上接受了语法上无效的 Python 程序。特别是,此限制阻止解析器识别赋值的左侧是否是有效的赋值目标。 

    之前链接的 PEP指出,旧的解析器甚至接受如下所示的代码:

    [x for x in y] = [1,2,3]
    
    

    但是,这段代码根本没有任何意义!

    事实上,完整的 Python 语法不允许使用此代码。为了解决这个问题,Python 中曾经存在一个单独的 hacky 阶段,它会检查所有分配并确保左侧实际上是可以分配的东西。这是在解析之后发生的,因此错误消息在旧版本的 Python 中被交换。

    第四轮

    好的,在我们的程序中修复了最后一个语法错误后,程序代码如下所示:

    1 / 0print(None)if False:    ñ = "hello"
    
    

    好吧,当然,现在下一个要报告的错误信息是第一行的错误。这会是正确的吗?

    🥁🥁🥁🥁

    Traceback (most recent call last):  File "/private/tmp/round_4.py", line 1, in     1 / 0    ~~^~~ZeroDivisionError: division by zero

    是的,终于对了!Python最后报告第一行有错误。

    另外请大家注意,此错误消息首次出现 Traceback (most recent call last)。 通常是Python 抛出错误消息的主要内容,此处首次出现在最后一轮中。

    这里发生了什么?

    Python现在终于能够开始运行代码了。一旦解决了所有语法错误,Python 最终能够解释第一行,其中它将数字除以零,从而引发称为ZeroDivisionError!的运行时错误。我们知道处于“运行时”,因为 Python 已经打印Traceback (most recent call last) 表明有堆栈跟踪。堆栈跟踪只能在运行时存在,这意味着该错误必须在运行时捕获。

    这意味着第 1-3 轮中遇到的错误是否也运行时错误?如果不是,它们是什么?

    关于 Python虚拟机(PVM)

    Python虚拟机(PVM),概念类似于JVM(Java虚拟机,懂Java语言的人都懂得)。为了使 Python 成为独立于机器/平台,以便让单个程序代码可以在任何机器/平台上执行,而无需重新写入特定硬件,Python 建立在称为 PVM(缩写为 PVM)的虚拟机概念之上。

    所以,Python 源代码首先被转换为字节码,这是一种用于称为“虚拟机”的通用机器的格式,而不是特定于特定机器/平台,随后特定机器上的 PVM 将字节码转换为确切的特定格式基于特定机器指令集的机器代码,例如 Windows 平台或 Linux 或 MacOS 等。

    在底层,Python 代码首先被编译成“字节码”。然后它被传递到 Python 虚拟机并在那里被解释。因此,我们可以将Python视为一种“编译解释型”语言。

    总而言之, Python 在本质上既是编译型语言,又是解释型语言,但由于解释性,它被称为解释型语言。

    Python 既是编译型又是解释型语言

    这才是正确的!CPython 解释器确实是一个解释器。但它也是一个编译器。我想上面的练习已经证明了。Python 在运行第一行代码之前必须经历几个阶段:

  • 扫描

  • 解析

  • 旧版本的 Python 添加了一个额外的阶段:

  • 扫描

  • 解析

  • 检查有效的分配目标

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

  • 预处理

  • 词法分析(“扫描”的另一个术语)

  • 句法分析(“解析”的另一个术语)

  • 语义分析

  • 链接

  • Python 在运行任何代码之前,仍然执行一些编译阶段。

    Python 确实会先编译代码,就如同 Java 一样,它会将源代码编译为字节码。错误报告的含义是, Python 具有编译器错误消息,但是并非 Python 中的每个错误消息都在运行时发生。

    事实上,上述四个错误消息中只有一个是在运行时引发的,即 ZeroDivisionError: division by zero.

    compileall

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

    $ python3 -m compileall .
    
    

    这会将当前目录中所有 Python 文件的已编译字节码放入__pycache__/目录,并显示任何编译器返回的错误。如果你想知道该__pycache__/文件夹中到底有什么, 我们有机会再来一篇文章!

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

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

    每当一种编程语言被归类为“编译”或“解释”语言时,这都是令我讨厌的事情。编程语言本身并不是编译或解释的;一种语言是编译型还是解释型(或两者兼而有之!)是一个实现细节。

    我并不是唯一一个有这种想法的人。Laurie Tratt 有一篇精彩的文章通过编写一个逐渐成为优化编译器的解释器来论证完全相同的观点。他说的更整洁!

    另一个很棒的资源是 Bob Nystrom 的 Crafting Interpreters一文。以下是他第 2 章中的一些引述:

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

    事实证明,这就像询问水果和蔬菜之间的区别一样。这看起来像是一个二元的、非此即彼的选择,但实际上“水果”是一个植物学术语,“蔬菜”是一个烹饪术语。其中一个并不严格,意味着对另一个的否定。有些水果不是蔬菜(苹果),也有的蔬菜不是水果(胡萝卜),但也有既是水果又是蔬菜的可食用植物,例如西红柿等。

    他的话揭露了一个有趣的事实:这是我第一次了解到西红柿的含义。

    在烹饪中,它们是一种蔬菜。从植物学角度来说,它们是一种水果。它们两者都是。他们也可以两者兼而有之,那没关系。

    等等,我们不是在谈论 Python 吗?我们回来了:

    当你使用 CPython 运行 Python 程序时,代码会被解析并转换为内部字节码格式,然后在 VM 内执行。

    从用户的角度来看,这显然是一个解释器——我们从源代码运行自己的程序。但如果你仔细观察 CPython 的表面,你会发现肯定有一些编译动作正在进行。

    答案是两者都是。CPython 是一个解释器,它有一个编译器。

    那么为什么这很重要呢?为什么严格区分“编译”语言和“解释”语言会适得其反?

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

    是的,编程语言不必通过它是编译型还是解释型来定义!

    如此僵化的思维方式,限制了我们相信特定编程语言可以做的事情。

    例如,JavaScript 通常被归为“解释语言”类别。但有一段时间,在 Google Chrome 中运行的 JavaScript永远不会被解释——相反,JavaScript被直接编译为机器代码!因此,JavaScript 可以与 C++ 并驾齐驱。

    正出于这个原因,我也有点厌倦了关于解释性语言必然很慢的争论——性能是多方面的,并且不仅仅取决于“默认”编程语言实现。比如JavaScript 现在速度很快,Ruby 现在速度很快,Lua 已经双加快了一段时间了。

    通常标记为编译型的编程语言又如何了呢?

    你想要一个 C 解释器吗?干得好! 现在我们可以编写具有未定义行为的 shell 脚本!

    给大家 REPL !

    我爱上编程的方式之一就是坐在Python 和 Basic 等语言的REPL。这种即时性让我对编程感到兴奋,也是我在日常生活中经常使用 REPL 的原因。但似乎只有解释型语言提供交互式提示,因此我们不能为编译型语言提供 REPL,对不对?

    我想提一下Glasgow Haskell 编译器(GHC)。我学习 Haskell 的方法之一(但很快就忘了😉)就是在交互式控制台中闲逛。但是名正言顺,GHC 确实可以编译 Haskell 代码。

    这使得作为“编译”语言不再是缺乏交互式解释器的借口!您想要类似于 Python 的交互式 Java 体验吗?事实证明,jshell从 2017 年起就与 Java 捆绑在一起了。

    说了这么多,这些部分分散了我们应该教给同学们的真正区别:

    真正的区别:静态与动态

    我们应该教给学生的真正区别:是以静态确定的语言属性(即只需盯着代码而不运行它)和只能在运行时动态了解的属性之间的区别 。

    请大家注意,我说的是“属性”而不是“语言”。每种编程语言都会选择自己的一组属性,这些属性可以静态或动态地确定,综合起来使语言更“动态”或更“静态”。静态与动态更像是一个频谱,是的,Python 属于该频谱中更动态的一端。像 Java 这样的语言比 Python 拥有更多的静态特性,但即使 Java 也包含像反射这样的东西,这无疑是一个动态语言的特性。

    我发现动态与静态经常与编译与解释相混淆。这是是可以理解的。

    通常,使用解释器的语言具有更多动态功能,例如 Python、PHP、Ruby 和 JavaScript。具有更多静态特性的语言往往无需解释器即可实现,例如 C++ 和 Rust。然后是介于两者之间的 Java。

    Python 中的静态类型注释已逐渐在代码库中得到采用。人们的期望之一是,由于更多的事物是静态已知的,这可以释放 Python 代码中的性能优势。不幸的是,事实并非那么简单。事实证明,Python 中的两种类型(一般类型或元类)和注释本身都是 Python 的动态功能,这使静态类型无法带来我们所期望的性能优势。

    结论

    Python 是编译型还是解释型的并不重要。重要的是,Python 中可以静态 (即运行前)确定的属性集相对较小,这意味着与具有更多静态功能的编程语言相比,在运行时往往会出现更多错误。这是真正重要的区别,它比“编译”与“解释”的区别更加细致和微妙。

    因此,我认为 在教授“解释型”语言和“编译型”语言之间略显乏味的区别时,强调特定的静态和动态特征是非常重要的。

    本文为 @ 万能的大雄 创作并授权21CTO发布,未经许可,请勿转载。

    内容授权事宜请您联系 webmaster@21cto.com或关注 21CTO 公众号。

    该文观点仅代表作者本人,21CTO 平台仅提供信息存储空间服务。

    相关文章

    塑造我成为 CTO 之路的“秘诀”
    “人工智能教母”的公司估值达 10 亿美金
    教授吐槽:985 高校成高级蓝翔!研究生基本废了,只为房子、票子……
    Windows 蓝屏中断提醒开发者:Rust 比 C/C++ 更好
    Claude 3.5 Sonnet 在伽利略幻觉指数中名列前茅
    上海新增 11 款已完成登记生成式 AI 服务

    发布评论