打字系统(如打字稿)的底层

2020-05-09 00:07:58

我努力从“JavaScript类型系统编译器”是如何实现的低级视图中找到解释。我了解类型系统的许多工作,但不确定涉及的机制以及它们如何协同工作。

本文旨在揭示幕后工作的一些基本原理。不可能把重点放在一篇文章中的所有内容上,所以这里我们将专门关注“类型检查”。从概述类型系统开始,然后构建我们自己的编译器,它可以运行类型检查并输出合理的消息。有关转换的更多帮助,请参阅我关于Web捆绑程序或源地图的文章或演讲。

这个演讲的视频可以在这里找到。我的“幕后黑手”系列的一部分。

注:很抱歉,这篇文章与我上一篇关于源地图的文章有一些重复。但重要的是,要包括所有必要的信息,以了解我们今天将看到的机制。

语法和语义之间的差异是需要及早回顾的重要内容。

通常是JavaScript原生的代码。实质上是询问给定的代码对于JavaScript运行时是否正确。例如,下面的语法是正确的:

这是特定于类型系统的代码。实质上是询问附加到代码的给定类型是否正确。例如,上面的代码在语法上是正确的,但在语义上是错误的(将变量定义为一个数字,但设置了一个字符串)。

在进一步讨论之前,我们需要快速了解一下任何JavaScript编译器AST中的一个重要机制。

AST代表“抽象语法树”,它基本上是一棵表示代码程序的“节点”树。“Node”是可能的最小单位,基本上是具有“type”和“location”属性的POJO(即普通老式js对象)。所有节点都具有这两个属性,但是基于“类型”,它们还可以具有各种其他属性。

在AST表单中,代码非常容易操作,因此添加、删除甚至替换等操作都是可以执行的。

有一些网站,比如https://astexplorer.net/,很擅长让你编写JavaScript代码,并立即看到它的AST。

本机编译器将代码转换成可由服务器或计算机运行的形式(即机器代码)。类似于Java生态系统中的编译器会将代码转换为字节码,然后再转换为本机代码。

语言编译器扮演着截然不同的角色。TypeScript和flow的编译器在将代码输出到JavaScript时都被归类为语言编译器。本机编译器的主要不同之处在于,它们出于工具目的(例如,优化代码性能或添加附加功能)而进行编译,而不是生成机器码。

让我们从基础开始吧。类型系统编译器中的几个核心作业包括:

这里我指的是引入“类型”(通常通过显式注释或隐式推理)和一种检查1个类型是否与另一个类型匹配的方法,例如字符串与数字。

要让类型系统在开发环境中工作,最好是它可以在IDE中运行任何类型检查,并为用户提供即时反馈。语言服务器将类型系统连接到IDE,它们可以在后台运行编译器,并在用户保存文件时重新运行。诸如TypeScript和Flow之类的流行语言都包含语言服务器。

许多类型系统包含本机Javascript不支持的代码(例如,类型注释不受支持),因此它们必须从不受支持的JavaScript转换为受支持的JavaScript。

正如在最上面提到的,我们将重点关注点(1)执行类型检查。如果它看起来有价值,我们可以在未来探索(2)语言服务器。我关于Web捆绑器和源代码映射的文章更详细地介绍了(3)代码转换。

接下来,我们将了解以高效和可伸缩的方式执行所有上述作业所需的步骤。对于大多数编译器来说,有3个共同的阶段,它们都有这样或那样的形式。

词法分析->;将代码字符串转换为令牌流(即数组)。

解析器检查给定代码的“语法”。类型系统必须拥有自己的解析器,通常包含数千行代码。

Babel解析器包含2100行代码,仅用于处理代码语句(请参见此处),这些语句可以理解任何特定于编译器的代码的语法分析,但也可以附加有关类型的附加信息。

Hegel将typeAnnotation属性附加到具有类型注释的代码中(您可以在这里看到它在做这件事)。

TypeScript的解析器有8600行庞大的代码(在这里可以找到它开始遍历树的位置)。它包含整个JavaScript超集,所有这些都需要解析器理解。

除了上面的步骤,类型系统编译器通常会在“解析”之后包括一个或两个额外的步骤,这将包括特定于类型的工作。

一份附注打字稿实际上在其编译器中总共有5个阶段,它们是:

正如您在上面看到的,语言服务器包含一个预处理器,它触发类型编译器只运行已更改的文件。这将跟随在任何“import”语句之后,以确定其他哪些内容可能已更改,并需要在下一次重新运行时包括在内。此外,编译器只能重新处理已更改的AST图的分支。下面关于“懒惰编译”的更多信息。

没有批注的代码需要推断。关于这个主题,这里有一篇非常有趣的博客文章,内容是什么时候使用类型注释,什么时候让引擎使用推理。

使用预定义算法,引擎将计算给定变量/函数的类型。

TypeScript在其绑定阶段(2次语义传递中的第1次)中使用“最佳通用类型”算法。它考虑每个候选类型,并选择与所有其他候选类型兼容的类型。上下文类型在这里起作用,即在推理中使用位置。在这里的打字规范中有更多关于这方面的帮助。TypeScript实际上引入了“符号”(这里是接口)的概念,这些是命名声明,它们将AST中的声明节点连接到对同一实体有贡献的其他声明。它们是打字文字语义系统的基本构件。

既然(1)已经完成并且已经分配了类型,引擎就可以运行它的类型检查了。它们检查给定代码的“语义”。这些类型的检查有多种类型,从类型不匹配到类型不存在。

对于TypeScript,这是检查器(第二次语义传递),它有20,000行代码长度。我觉得这给了我一个非常强烈的想法,即在这么多不同的场景中检查这么多不同的类型是多么的复杂和困难。

类型检查器不依赖于调用代码,即文件是否执行其自己的任何代码(即在运行时)。类型检查器将自己处理给定文件中的每一行,并运行相应的检查。

以下是几个额外的概念,由于它们所涉及的复杂性,我们今天将不再深入挖掘它们:

现代编译的一个共同特征是“延迟加载”。除非绝对需要,否则它们不会重新计算或重新编译文件或AST分支。

打字脚本预处理器可以使用上一次运行时存储在内存中的AST代码。这有一个巨大的性能提升,因为它可以只专注于运行程序或节点树中已更改的一小部分。TypeScript使用不变的只读数据结构,存储在它所说的“旁观表”中。这使得我们很容易知道什么发生了变化/没有发生变化。

有些操作编译器在编译时不知道是安全的,必须等待运行时。每个编译器都必须做出艰难的选择,以决定哪些内容将被包括,哪些内容不将被包括。TypeScript有某些区域被认为是“不健全的”(即需要运行时的类型检查)。

我们不会在我们的编译器中处理上述功能,因为它们增加了额外的复杂性,对于我们的小POC来说不值得这样做。

我们将构建一个编译器,它可以针对3个不同的场景运行类型检查,并为每个场景抛出特定的消息。我们将其限制在3个场景的原因是,这样我们就可以专注于围绕每个场景工作的具体机制,并希望在结束时对如何引入更复杂的类型检查有一个真正强有力的想法。

我们将在编译器中使用函数声明和表达式(调用该函数)。

在我们的编译器上,我们的编译器有两个部分,解析器和检查器。

如前所述,今天我们不会关注解析器。我们将遵循黑格尔解析方法,即假设已将typeAnnotation对象附加到所有带注释的AST节点。我已经硬编码了AST对象。

您可以看到我们的第一行表达式语句的expression sionAstblock,以及我们在第二行声明函数的位置的声明Ast。我们返回一个ProgramAst,它是一个包含两个AST块的程序。

在AST内部,您可以看到参数标识符“a”上的typeAnnotation值,与它在代码中的位置相匹配。

它在表达式、声明和程序AST块方面与场景1非常相似。但是,不同之处在于params中的typeAnnotation是make_up_type,而不是场景1中的NumberTypeAnnotation。

除了表达式、声明和程序AST块之外,还有一个interfaceAst块,它保存InterfaceClaimation的AST。声明Ast现在的注释上有GenericType,因为它接受对象标识符,即Person。在此场景中,ProgramAst将返回这3个对象的数组。

从上面可以看到,保存所有3个场景的类型注释的主要区域是声明参数。这三家公司在这一点上都有共同之处。

现在转到编译器执行类型检查的部分。它需要遍历所有程序体AST对象,并根据节点类型执行适当的类型检查。我们将把任何错误添加到数组中,以返回给调用者进行打印。

在我们进一步讨论之前,我们将针对每种类型使用的基本逻辑是:

函数声明:检查参数的类型是否有效,然后检查块体中的每条语句。

表达式:找到调用者的函数声明,获取声明参数上的类型,最后获取表达式调用者参数的类型并进行比较。

这个要点包含typeChecks对象(和错误数组),它将用于检查我们的表达式和基本注释检查。

对于NumberTypeAnnotation;调用方类型应为NumericWrital(即,如果注释为数字,则调用方类型应为数字)。场景1将在此处失败,但尚未记录任何内容。

对于GenericTypeAnnotation;如果它是一个对象,我们在树中搜索InterfaceClaimation,然后检查该接口上调用方的每个属性。任何问题都会被推送到错误数组中,并带有一条有用的消息,说明确实存在什么属性名称,因此它实际上可能是什么。场景3将在此处失败,并收到此错误。

我们的处理仅限于该文件,但是大多数类型检查器都有“作用域”的概念,因此它们能够确定声明是否在运行时的任何位置。我们的工作比较容易,因为它只是一个POC。

这一要点包含对程序体中每种节点类型的处理。上面的类型检查逻辑就是从这里调用的。

首先处理参数/参数。如果找到类型注释,请检查给定参数(即argType)是否存在该类型。如果它没有将错误添加到错误中,则。场景2在这里会出现错误。

最后,我们处理函数体,但是,因为我们知道没有函数体要处理,所以我将其留空。

首先检查程序体中的函数声明。这就是Scope适用于真正的类型检查器的地方。如果未找到声明,则向Errors数组添加错误。

接下来,我们对照调用方参数类型检查每个定义的参数类型。如果发现类型不匹配,则将错误添加到错误数组中。场景1和场景2都会收到此错误。

我已经介绍了一个带有简单索引文件的基本存储库,它可以一次性处理所有3个AST节点对象并记录错误。当我运行它时,我得到以下结果:

我们在函数参数上定义了一个不存在的类型,然后调用了函数,因此得到2个错误(1个是定义的类型不正确,1个是类型不匹配)。

我们定义了一个接口,但是使用了一个名为nam的属性,而该属性不在对象上,系统会询问我们是否打算使用name。

如前所述,我们在编译器中省略了类型编译器的许多附加部分。其中一些是:

解析器:我们手动编写了AST块,这些块将在真实类型编译器上生成。

预处理/语言编译器:真正的编译器具有插入IDE并在适当时间重新运行的机制。

转换:我们跳过了编译器的最后部分,也就是生成本机JavaScript代码的地方。

作用域:因为我们的POC是单个文件,所以它不需要理解“作用域”的概念,但是真正的编译器必须始终知道上下文。

非常感谢您的阅读或收看,我从这次研究中学到了很多关于打字系统的知识,希望能对您有所帮助。您可以在这里找到所有这些代码的存储库。如果你喜欢的话请鼓掌。