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

23

我最近为了好玩读了整本龙书(编译器原理),结果留下了一个大问题。

编写编译器和解释器之间有什么不同?

对于我来说,编译器由以下组成:

  • 词法分析器
  • 解析器(构建语法树)
  • 生成中间代码(比如3地址码)
  • 进行各种疯狂的优化(如果需要的话)
  • 从3地址码生成“汇编”或“本机代码”。

当然,解释器也有与编译器相同的词法分析器和解析器。
但是它在这之后做了什么呢?

  • 它是否直接“读取”语法树并执行它?(有点像指令指针指向树中当前节点,并且执行是一次大型树遍历加上调用栈的内存管理)(如果是这样,它是如何实现的?我希望执行方式比巨大的 switch 语句要好)

  • 它是否生成3地址码并解释执行?(如果是这样,它是如何实现的?再次强调,我希望看到比一英里长的 switch 语句更优雅的东西)

  • 它是否生成真正的本机代码,将其加载到内存中并运行?(此时我想它不再是解释器,而更像 JIT 编译器)

此外,“虚拟机”这个概念在什么时候出现?编程语言中需要虚拟机用来做什么?(为了使我的无知清晰明了,对我来说,虚拟机就是 VMWare,我不知道它如何应用于编程语言/程序执行)。

可以看出,我的问题相当广泛。我主要想了解的不仅是使用哪种方法,而是先理解大体概念,然后深入了解其详细工作原理。我想要那些丑陋、原始的细节。显然,这更像是寻找需要阅读的参考资料,而不是期望你在这里回答所有这些细节。

谢谢!
丹尼尔


编辑:感谢您迄今为止提供的答案。但我意识到我的标题有误导性了。我理解编译器和解释器之间的“功能”差异。
我想知道的是如何实现解释器与编译器的区别。
我现在明白了编译器是如何实现的,问题是解释器与之不同之处。

例如,VB6显然既是编译器又是解释器。我现在理解了编译器部分。然而,我无法理解它在IDE中运行时如何让我在任意点停止程序、更改代码并使用新代码继续执行。 这只是一个微小的例子,不是我要找的答案。如下所述,我试图理解的是,在我拥有解析树后发生了什么。编译器会在“目标”语言中从中生成新代码。那么解释器会做什么呢?

谢谢您的帮助!


我可以建议一个不同的问题 - 我如何实现一个解释器? 编辑并继续可以是解释或编译功能。 - plinth
10个回答

8

编译器是一种将一个编程语言的程序转换为另一种编程语言程序的程序。就是这样 - 简单明了。

解释器将编程语言翻译成其语义含义。

x86芯片是x86机器语言的解释器。

Javac是Java到Java虚拟机的编译器,可执行应用程序Java则是JVM的解释器。

有些解释器在某些方面与编译有相似之处,因为它们可以将一种语言翻译成另一种更容易解释的内部语言。

解释器通常具有读取-求值-打印(Repl)循环,但并不总是如此。


1
读取-评估-打印循环:听起来很有趣,我会进行一些阅读。谢谢! - Daniel Magliola

8

一个程序是你想要完成的工作的描述。

编译器将高级描述转换为更简单的描述。

解释器读取所需操作的描述并执行该操作

  • 一些解释器(例如Unix shell)逐个小块地读取描述,并根据其看到的每个部分进行操作;一些解释器(例如Perl、Python)会读取整个描述,内部将其转换为更简单的形式,然后再执行。
  • 一些解释器(例如Java的JVM或Pentium 4芯片)只能理解非常简单的描述语言,这种语言对人类来说太繁琐了,因此人们使用编译器将他们的高级描述转换为这种语言。

编译器从不执行操作。解释器总是执行操作。


1
你提到处理器作为解释器确实很有趣。这让我对此有了新的看法。 - Daniel Magliola
1
谢谢,实际上 plinth 是第一个这样描述它的。 - j_random_hacker

7

简而言之:

  • 编译器将源代码转换为可供以后执行的可执行格式
  • 解释器评估源代码以立即执行

在实现方式上有很大的灵活性。解释器可以生成本地机器代码,然后执行,而面向虚拟机的编译器可能会生成p-code而不是机器码。像Forth这样的线程解释语言会查找字典中的关键字并立即执行其关联的本地代码函数。

编译器通常可以更好地进行优化,因为它们有更多时间来研究代码并生成文件以供以后执行;解释器需要更少的时间来优化,因为它们倾向于在第一眼看到代码时就执行代码"原样"。

同时也存在一种可以在后台进行优化、学习更好的执行代码方式的解释器。

总结:区别实际上只在于“为以后执行准备代码”或“立即执行代码”。


现在,如果解释器生成机器代码并执行它,那么这与编译器有什么不同呢?只是它每次都编译吗? - Daniel Magliola
另外,你有更多关于解释器实现的信息吗?有什么我可以阅读的资料吗?谢谢! - Daniel Magliola
@[Daniel Magliola]:关于问题1,是的,它每次都编译通过,并且不会输出编译结果以供检查;虽然有一些解释器可以这样做,但每次重新执行所有工作非常耗费资源。 - Steven A. Lowe
@[Daniel Magliola]:关于第二点,解释器的核心是执行"evaluate"或者"execute"函数,它会立即执行输入的源代码;有很多方法可以实现这一点。你最好查阅每种你感兴趣的语言的解释器的工作原理——比如基础、Python、Lisp等。 - Steven A. Lowe
《龙书》在第一章早期就回答了这个问题。 - lshepstone

4
两者有很多共同之处(例如词法分析器),但对于它们的区别存在争议。我这样看待:
传统定义是编译器将符号流解析并翻译为可由CPU运行的字节流,而解释器也执行相同的操作,但将其翻译为必须在软件上执行的形式(例如JVM,CLR)。
然而,人们称'javac'为编译器,因此编译器的非正式定义是必须作为单独步骤完成的源代码处理,而解释器没有“构建”步骤(例如PHP,Perl)。

1
我会点赞你,但是有很多复杂性。例如,Python解释器有一个构建步骤(在需要时将py编译为pyc)。此外,一些CPU的“真实”语言是微码,选择在其上方模拟“可见”的CPU。 - paxdiablo
解释器不会将输入转换为“必须在软件上执行的形式” - 它可能会或可能不会在内部转换输入,但主要的是它最终会执行代码!就在那里和那时! - j_random_hacker
大多数人会将PHP视为解释器,但我向您保证它将纯文本源代码编译成中间形式。毕竟,这就是PHP加速器(opcode缓存)如APC的工作原理。 - cletus

3
这个问题并不像以前那么清晰明了。以前的做法是建立一个解析树,绑定它,然后执行它(通常在最后一刻绑定)。BASIC 大多数都是这样完成的。你可以说运行字节码(Java/.NET)而不进行 JIT 的东西是解释器——但不是传统意义上的解释器,因为你仍然必须“编译”成字节码。旧学派的区别是:如果它生成 CPU 代码,那就是编译器。如果你可以在编辑环境中直接运行它并在编辑时与之交互,那就是解释器。这比实际的 Dragon 书籍要不正式得多,但我希望它能提供信息。

你能提供更多关于“绑定”的细节吗? 另外,在绑定之后,它是如何执行的? - Daniel Magliola

2
关于你问题中的这一部分,其他答案并没有真正解决:

此外,“虚拟机”概念在什么时候出现?在语言中使用虚拟机有什么用途?

像JVM或CLR这样的虚拟机是一层抽象,允许您重用针对完全不同编译为在VM上运行的语言的JIT编译器优化、垃圾收集和其他实现细节。

它们还可以帮助您使语言规范更独立于实际硬件。例如,虽然C代码理论上是可移植的,但如果您想要生成可移植的代码,您需要不断担心字节序、类型大小和变量对齐等问题。而对于Java来说,JVM在这些方面非常清楚地指定了规范,因此语言设计者及其用户不必担心这些问题;JVM实现者的工作是在实际硬件上实现指定的行为。


谢谢,讲得非常清楚。现在,为了确保我理解正确,VM只是一个“目标机器”,用于生成代码,该代码将获取您的“编译”代码并将其编译为真实机器或解释并运行它,对吗?这就像将编译分成两个独立的步骤? - Daniel Magliola

2
如果我的经验有所指示,那么:
  1. 解释器不会试图进一步减少/处理AST,每次引用代码块时,相关的AST节点将被遍历和执行。编译器最多遍历一个代码块几次,以在确定的位置生成可执行代码并完成。
  2. 解释器的符号表在执行时保留值和引用,而编译器的符号表保留变量的位置。在执行期间没有这样的符号表。
简而言之,区别可能就是这么简单。
case '+':
    symtbl[var3] = symtbl[var1] + symtbl[var2];
    break;

之间,

case '+':
    printf("%s = %s + %s;",symtbl[var3],symtbl[var1],symtbl[var2]);
    break;

(无论您针对其他语言或(虚拟)机器指令,这都没有关系。)

基本上,这是树的遍历,再加上一个非常庞大的switch语句? - Daniel Magliola
如果您的实现语言支持具有虚拟方法的类,则不一定需要可见开关。但是,是的,在遍历树时所做的操作是有区别的。 - artificialidiot

1

一旦有了解析树,就有几种策略:

1)直接解释AST(Ruby,WebKit的原始解释器) 2)代码转换 -> 转换成字节码或机器码

为了实现编辑和继续,程序计数器或指令指针必须重新计算和移动。这需要IDE的合作,因为代码可能已经在小黄箭头之前或之后插入。

一种方法是将程序计数器的位置嵌入解析树中。例如,可能会有一个特殊的语句称为“break”。程序计数器只需要定位到“break”指令之后即可继续运行。

此外,您还必须决定如何处理当前的堆栈帧(以及堆栈上的变量)。也许弹出当前堆栈,并复制变量,或保留堆栈,但在当前代码中打补丁GOTO和RETURN。


0

如果你正在寻找一本书,计算机程序的构造与解释(“向导书”)是理解解释器概念的好起点。你只需要处理Scheme代码,它可以像AST一样遍历、评估和传递。

此外,彼得·诺维格用Python举了一个简单的例子来解释主要思想(评论区还有更多例子),另外在维基百科上也有一个小例子

就像你所说的,这是一种树形遍历,对于按值调用至少是简单的:每当你看到一个操作符,先评估操作数,然后应用运算符。返回的最终值是程序的结果(或给REPL的语句)。

请注意,你不必总是明确地进行树形遍历:你可以以接受访问者的方式生成AST(我认为SableCC就是这么做的),或者对于非常小的语言,比如用于演示解析器生成器的小算术语法,你可以在解析时直接计算结果。

为了支持声明和赋值,您需要保留一个环境。就像通过添加操作数来评估“加”一样,您可以通过在环境中查找函数、变量等的名称来评估它们。支持作用域意味着在正确的时间将环境视为堆栈并推入和弹出内容。通常,解释器的复杂程度取决于您要支持哪些语言特性。例如,解释器使垃圾收集和内省成为可能。
对于虚拟机:plinth和j_random_hacker将计算机硬件描述为一种解释器。反之亦然--解释器是机器;它们的指令恰好比真实ISA的指令更高级别。对于VM风格的解释器,程序实际上类似于机器代码,尽管是针对非常简单的机器。Java bytecode只使用了几个“寄存器”,其中一个保存程序计数器。因此,VM解释器更像是硬件仿真器,而不是我上面链接示例中的解释器。
但请注意,出于速度原因,默认的Oracle JVM通过将Java字节码指令的运行转换为x86指令(“即时编译”)来工作。

0

根据您的步骤列表:

  • 词法分析器
  • 解析器(构建语法树)
  • 生成中间代码(如三地址代码)
  • 如果需要,进行所有这些疯狂的优化 :-)
  • 从三地址代码生成“汇编”或“本机代码”。

一个非常简单的解释器(例如早期的BASIC或TCL)只会一次执行一行的前两个步骤。然后丢弃大部分结果,继续执行下一行要执行的操作。其他三个步骤将根本不会执行。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接