这篇文章包含两个部分:

A 部分:类型系统编译器概述(包括 TypeScript)

  • Syntax vs Semantics 语法 vs 语义
  • What is AST? 什么是 AST?
  • Types of compilers 编译器的类型
  • What does a language compiler do? 语言编译器是做什么的?
  • How does a language compiler work? 语言编译器是如何工作的?
  • Type system compiler jobs 类型系统编译器职责
  • Advanced type checker features 高级类型检查器的功能

B 部分:构建我们自己的类型系统编译器

  • The parser 解析器
  • The checker 检查器
  • Running our compiler 运行我们的编译器
  • What have we missed? 我们遗漏了什么?

A 部分:类型系统编译器概述

语法 vs 语义

语法和语义之间的区别对于早期的运行很重要。

语法 - Syntax

语法通常是指 JavaScript 本机代码。本质上是询问给定的 JavaScript 代码在运行时是否正确。

例如,下面的语法是正确的:

var foo: number = "not a number";

语义 - Semantics

这是特定于类型系统的代码。本质上是询问附加到代码中的给定类型是否正确。

例如,上面的代码在语法上是正确的,但在语义上是错误的(将变量定义为一个数字类型,但是值是一个字符串)。

接下来是 JavaScript 生态系统中的 AST 和编译器。

什么是 AST?

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

关于 AST 详细介绍请看这篇文章。

AST 的意思是抽象语法树 ,它是一个表示程序代码的节点树。Node 是最小单元,基本上是一个具有 typelocation 属性的 POJO(即普通 JavaScript 对象)。所有节点都有这两个属性,但根据类型,它们也可以具有其他各种属性。

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

例如下面这段代码:

function add(number) {return number + 1;
}

将解析成以下 AST:

编译器类型

在 JavaScript 生态系统中有两种主要的编译器类型:

1. 原生编译器(Native compiler)

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

2. 语言编译器

语言编译器扮演着不同的角色。TypeScript 和 Flow 的编译器在将代码输出到 JavaScript 时都算作语言编译器。

语言编译器与原生编译器的主要区别在于,前者的编译目的是 tooling-sake(例如优化代码性能或添加附加功能),而不是为了生成机器代码。

语言编译器是做什么的?

在类型系统编译器中,总结的两个最基本的核心职责是:

1. 执行类型检查

引入类型(通常是通过显式注解或隐式推理),以及检查一种类型是否匹配另一种类型的方法,例如 stringnumber

2. 运行语言服务器

对于一个在开发环境中工作的类型系统(type system)来说,最好能在 IDE 中运行任何类型检查,并为用户提供即时反馈。

语言服务器将类型系统连接到 IDE,它们可以在后台运行编译器,并在用户保存文件时重新运行。流行的语言,如 TypeScript 和 Flow 都包含一个语言服务器。

3. 代码转换

许多类型系统包含原生 JavaScript 不支持的代码(例如不支持类型注解) ,因此它们必须将不受支持的 JavaScript 转换为受支持的 JavaScript 代码。

关于代码转换更详细的介绍,可以参考原作者的这两篇文章 Web Bundler 和 Source Maps。

语言编译器是如何工作的?

对于大多数编译器来说,在某种形式上有三个共同的阶段。

1. 将源代码解析为 AST

  • 词法分析 -> 将代码字符串转换为令牌流(即数组)
  • 语法分析 -> 将令牌流转换为 AST 表示形式

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

Babel 解析器 中的 2200+ 行代码,仅用于处理 statement 语句(请参阅此处)。

Hegel 解析器将 typeAnnotation 属性设置为具有类型注解的代码(可以在这里看到)。

TypeScript 的解析器拥有 8900+ 行代码(这里是它开始遍历树的地方)。它包含了一个完整的 JavaScript 超集,所有这些都需要解析器来理解。

2. 在 AST 上转换节点 - 操作 AST 节点

这里将执行应用于 AST 的任何转换。

3. 生成源代码 - 将 AST 转换为 JavaScript 源代码字符串

类型系统必须将任何非 js 兼容的 AST 映射回原生 JavaScript。

类型系统如何处理这种情况呢?

类型系统编译器(compiler)职责

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

顺便说一下,TypeScript 的编译器实际上有 5 个阶段,它们是:

  1. 语言服务预处理器 - Language server pre-processor
  2. 解析器 - Parser
  3. 结合器 - Binder
  4. 检查器 - Checker
  5. 发射器 - Emitter

正如上面看到的,语言服务器包含一个预处理器,它触发类型编译器只在已更改的文件上运行。这会监听任意的 import 语句,来确定还有哪些内容可能发生了更改,并且需要在下次重新运行时携带这些内容。

此外,编译器只能重新处理 AST 结构中已更改的分支。关于更多 lazy compilation,请参阅下文。

类型系统编译器有两个常见的职责:

1. 推导 - Inferring

对于没有注解的代码需要进行推断。关于这点,这里推荐一篇关于何时使用类型注解和何时让引擎使用推断的文章。

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

TypeScript 在其 Binding 阶段(两次语义传递中的第一次)中使用最佳公共类型算法。它考虑每个候选类型并选择与所有其他候选类型兼容的类型。上下文类型在这里起作用,也会做为最佳通用类型的候选类型。在这里的 TypeScript 规范中有更多的帮助。

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

TypeScript 实际上引入了 Symbols(interface)的概念,这些命名声明将 AST 中的声明节点与其他声明进行连接,从而形成相同的实体。它们是 TypeScript 语义系统的基本构成。

2. 检查 - Checking

现在类型推断已经完成,类型已经分配,引擎可以运行它的类型检查。他们检查给定代码的 semantics。这些类型的检查有很多种,从类型错误匹配到类型不存在。

对于 TypeScript 来说,这是 Checker (第二个语义传递) ,它有 20000+ 行代码。

我觉得这给出了一个非常强大的 idea,即在如此多的不同场景中检查如此多的不同类型是多么的复杂和困难。

类型检查器不依赖于调用代码,即如果一个文件中的任何代码被执行(例如,在运行时)。类型检查器将处理给定文件中的每一行,并运行适当的检查。

高级类型检查器功能

由于这些概念的复杂性,我们今天不深入探讨以下几个概念:

懒编译 - Lazy compilation

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

TypeScript 预处理程序可以使用缓存在内存中的前一次运行的 AST 代码。这将大大提高性能,因为它只需要关注程序或节点树的一小部分已更改的内容。

TypeScript 使用不可变的只读数据结构,这些数据结构存储在它所称的 look aside tables 中。这样很容易知道什么已经改变,什么没有改变。

稳健性

在编译时,有些操作编译器不确定是安全的,必须等待运行时。每个编译器都必须做出困难的选择,以确定哪些内容将被包含,哪些不会被包含。TypeScript 有一些被称为不健全的区域(即需要运行时类型检查)。

我们不会在编译器中讨论上述特性,因为它们增加了额外的复杂性,对于我们的小 POC 来说不值得。

现在令人兴奋的是,我们自己也要实现一个编译器。

B 部分:构建我们自己的类型系统编译器

我们将构建一个编译器,它可以对三个不同的场景运行类型检查,并为每个场景抛出特定的信息。

我们将其限制在三个场景中的原因是,我们可以关注每一个场景中的具体机制,并希望到最后能够对如何引入更复杂的类型检查有一个更好的构思。

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

这些场景包括:

1. 字符串与数字的类型匹配问题

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

2. 使用未定义的未知类型

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

3. 使用代码中未定义的属性名

interface Person {name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

实现我们的编译器,需要两部分:解析器检查器

解析器 - Parser

前面提到,我们今天不会关注解析器。我们将遵循 Hegel 的解析方法,假设一个 typeAnnotation 对象已经附加到所有带注解的 AST 节点中。我已经硬编码了 AST 对象。

场景 1 将使用以下解析器:

字符串与数字的类型匹配问题

function parser(code) {// fn("craig-string");const expressionAst = {type: "ExpressionStatement",expression: {type: "CallExpression",callee: {type: "Identifier",name: "fn"},arguments: [{type: "StringLiteral", // Parser "Inference" for type.value: "craig-string"}]}};// function fn(a: number) {}const declarationAst = {type: "FunctionDeclaration",id: {type: "Identifier",name: "fn"},params: [{type: "Identifier",name: "a",// 参数标识typeAnnotation: {// our only type annotationtype: "TypeAnnotation",typeAnnotation: {// 数字类型type: "NumberTypeAnnotation"}}}],body: {type: "BlockStatement",body: [] // "body" === block/line of code. Ours is empty}};const programAst = {type: "File",program: {type: "Program",body: [expressionAst, declarationAst]}};// normal AST except with typeAnnotations onreturn programAst;
}

可以看到场景 1 中,第一行 fn("craig-string") 语句的 AST 对应 expressionAst,第二行声明函数的 AST 对应 declarationAst。最后返回一个 programmast,它是一个包含两个 AST 块的程序。

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

场景 2 将使用以下解析器:

使用未定义的未知类型

function parser(code) {// fn("craig-string");const expressionAst = {type: "ExpressionStatement",expression: {type: "CallExpression",callee: {type: "Identifier",name: "fn"},arguments: [{type: "StringLiteral", // Parser "Inference" for type.value: "craig-string"}]}};// function fn(a: made_up_type) {}const declarationAst = {type: "FunctionDeclaration",id: {type: "Identifier",name: "fn"},params: [{type: "Identifier",name: "a",typeAnnotation: {// our only type annotationtype: "TypeAnnotation",typeAnnotation: {// 参数类型不同于场景 1type: "made_up_type" // BREAKS}}}],body: {type: "BlockStatement",body: [] // "body" === block/line of code. Ours is empty}};const programAst = {type: "File",program: {type: "Program",body: [expressionAst, declarationAst]}};// normal AST except with typeAnnotations onreturn programAst;
}

场景 2 的解析器的表达式、声明和程序 AST 块非常类似于场景 1。然而,区别在于 params 内部的 typeAnnotationmade_up_type,而不是场景 1 中的 NumberTypeAnnotation

typeAnnotation: {type: "made_up_type" // BREAKS
}

场景 3 使用以下解析器:

使用代码中未定义的属性名

function parser(code) {// interface Person {//   name: string;// }const interfaceAst = {type: "InterfaceDeclaration",id: {type: "Identifier",name: "Person",},body: {type: "ObjectTypeAnnotation",properties: [{type: "ObjectTypeProperty",key: {type: "Identifier",name: "name",},kind: "init",method: false,value: {type: "StringTypeAnnotation",},},],},};// fn({nam: "craig"});const expressionAst = {type: "ExpressionStatement",expression: {type: "CallExpression",callee: {type: "Identifier",name: "fn",},arguments: [{type: "ObjectExpression",properties: [{type: "ObjectProperty",method: false,key: {type: "Identifier",name: "nam",},value: {type: "StringLiteral",value: "craig",},},],},],},};// function fn(a: Person) {}const declarationAst = {type: "FunctionDeclaration",id: {type: "Identifier",name: "fn",},params: [{type: "Identifier",name: "a",// typeAnnotation: {type: "TypeAnnotation",typeAnnotation: {type: "GenericTypeAnnotation",id: {type: "Identifier",name: "Person",},},},},],body: {type: "BlockStatement",body: [], // Empty function},};const programAst = {type: "File",program: {type: "Program",body: [interfaceAst, expressionAst, declarationAst],},};// normal AST except with typeAnnotations onreturn programAst;
}

除了表达式、声明和程序 AST 块之外,还有一个 interfaceAst 块,它负责保存 InterfaceDeclaration AST。

declarationAst 块的 typeAnnotation 节点上有一个 GenericType,因为它接受一个对象标识符,即 Person。在这个场景中,programAst 将返回这三个对象的数组。

解析器的相似性

从上面可以得知,这三种有共同点, 3 个场景中保存所有的类型注解的主要区域是 declaration

检查器

现在来看编译器的类型检查部分。

它需要遍历所有程序主体的 AST 对象,并根据节点类型进行适当的类型检查。我们将把所有错误添加到一个数组中,并返回给调用者以便打印。

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

  • 函数声明:检查参数的类型是否有效,然后检查函数体中的每个语句。
  • 表达式:找到被调用的函数声明,获取声明上的参数类型,然后获取函数调用表达式传入的参数类型,并进行比较。

代码

以下代码中包含 typeChecks 对象(和 errors 数组) ,它将用于表达式检查和基本的注解(annotation)检查。

const errors = [];// 注解类型
const ANNOTATED_TYPES = {NumberTypeAnnotation: "number",GenericTypeAnnotation: true
};// 类型检查的逻辑
const typeChecks = {// 比较形参和实参的类型expression: (declarationFullType, callerFullArg) => {switch (declarationFullType.typeAnnotation.type) {// 注解为 number 类型case "NumberTypeAnnotation":// 如果调用时传入的是数字,返回 truereturn callerFullArg.type === "NumericLiteral";// 注解为通用类型case "GenericTypeAnnotation": // non-native// 如果是对象,检查对象的属性if (callerFullArg.type === "ObjectExpression") {// 获取接口节点const interfaceNode = ast.program.body.find(node => node.type === "InterfaceDeclaration");const properties = interfaceNode.body.properties;//遍历检查调用时的每个属性properties.map((prop, index) => {const name = prop.key.name;const associatedName = callerFullArg.properties[index].key.name;// 没有匹配,将错误信息存入 errorsif (name !== associatedName) {errors.push(`Property "${associatedName}" does not exist on interface "${interfaceNode.id.name}". Did you mean Property "${name}"?`);}});}return true; // as already logged}},annotationCheck: arg => {return !!ANNOTATED_TYPES[arg];}
};

让我们来看一下代码,我们的 expression 有两种类型的检查:

  • 对于 NumberTypeAnnotation; 调用时类型应为 AnumericTeral(即,如果注解为数字,则调用时类型应为数字)。场景 1 将在此处失败,但未记录任何错误信息。
  • 对于 GenericTypeAnnotation; 如果是一个对象,我们将在 AST 中查找 InterfaceDeclaration 节点,然后检查该接口上调用者的每个属性。之后将所有错误信息都会被存到 errors 数组中,场景 3 将在这里失败并得到这个错误。

我们的处理仅限于这个文件中,大多数类型检查器都有作用域的概念,因此它们能够确定声明在运行时的准确位置。我们的工作更简单,因为它只是一个 POC

以下代码包含程序体中每个节点类型的处理。这就是上面调用类型检查逻辑的地方。

// Process program
ast.program.body.map(stnmt => {switch (stnmt.type) {case "FunctionDeclaration":stnmt.params.map(arg => {// Does arg has a type annotation?if (arg.typeAnnotation) {const argType = arg.typeAnnotation.typeAnnotation.type;// Is type annotation validconst isValid = typeChecks.annotationCheck(argType);if (!isValid) {errors.push(`Type "${argType}" for argument "${arg.name}" does not exist`);}}});// Process function "block" code herestnmt.body.body.map(line => {// Ours has none});return;case "ExpressionStatement":const functionCalled = stnmt.expression.callee.name;const declationForName = ast.program.body.find(node =>node.type === "FunctionDeclaration" &&node.id.name === functionCalled);// Get declarationif (!declationForName) {errors.push(`Function "${functionCalled}" does not exist`);return;}// Array of arg-to-type. e.g. 0 = NumberTypeAnnotationconst argTypeMap = declationForName.params.map(param => {if (param.typeAnnotation) {return param.typeAnnotation;}});// Check exp caller "arg type" with declaration "arg type"stnmt.expression.arguments.map((arg, index) => {const declarationType = argTypeMap[index].typeAnnotation.type;const callerType = arg.type;const callerValue = arg.value;// Declaration annotation more important hereconst isValid = typeChecks.expression(argTypeMap[index], // declaration detailsarg // caller details);if (!isValid) {const annotatedType = ANNOTATED_TYPES[declarationType];// Show values to user, more explanatory than typeserrors.push(`Type "${callerValue}" is incompatible with "${annotatedType}"`);}});return;}
});

让我们再次遍历代码,按类型对其进行分解。

FunctionDeclaration (即 function hello(){})

首先处理 arguments/params。如果找到类型注解,就检查给定参数的类型 argType 是否存在。如果不进行错误处理,场景 2 会在这里报错误。

之后处理函数体,但是我们知道没有函数体需要处理,所以我把它留空了。

stnmt.body.body.map(line => {// Ours has none
});

ExpressionStatement (即 hello())

首先检查程序中函数的声明。这就是作用域将应用于实际类型检查器的地方。如果找不到声明,就将错误信息添加到 errors 数组中。

接下来,我们针对调用时传入的参数类型(实参类型)检查每个已定义的参数类型。如果发现类型不匹配,则向 errors 数组中添加一个错误。场景 1 和场景 2 在这里都会报错。

运行我们的编译器

源码存放在这里,该文件一次性处理所有三个 AST 节点对象并记录错误。

运行它时,我得到以下信息:

总而言之:

场景 1:

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

我们定义参数为 number 的类型,然后用字符串调用它。

场景 2:

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

我们在函数参数上定义了一个不存在的类型,然后调用我们的函数,所以我们得到了两个错误(一个是定义的错误类型,另一个是类型不匹配的错误)。

场景 3:

interface Person {name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

我们定义了一个接口,但是使用了一个名为 nam 的属性,这个属性不在对象上,错误提示我们是否要使用 name

我们遗漏了什么?

如前所述,类型编译器还有许多其他部分,我们在编译器中省略了这些部分。其中包括:

  • 解析器:我们是手动编写的 AST 代码,它们实际上是在类型的编译器上解析生成。
  • 预处理/语言编译器: 一个真正的编译器具有插入 IDE 并在适当的时候重新运行的机制。
  • 懒编译:没有关于更改或内存使用的信息。
  • 转换:我们跳过了编译器的最后一部分,也就是生成本机 JavaScript 代码的地方。
  • 作用域:因为我们的 POC 是一个单一的文件,它不需要理解作用域的概念,但是真正的编译器必须始终知道上下文。

非常感谢您的阅读和观看,我从这项研究中了解了大量关于类型系统的知识,希望对您有所帮助。以上完整代码您可以在这里找到。(给原作者 start)

备注:

原作者在源码中使用的 Node 模块方式为 ESM(ES Module),在将源码克隆到本地后,如果运行不成功,需要修改 start 指令,添加启动参数 --experimental-modules

"start": "node --experimental-modules src/index.mjs",

原文:https://indepth.dev/under-the-hood-of-type-systems/

预定义类型未定义或导入_探索类型系统的底层 - 自己实现一个 TypeScript相关推荐

  1. 预定义类型未定义或导入_LimeSurvey基础教程(二)——问题类型

    在上一讲中,我们主要介绍了LimeSurvey的历史.功能.结构以及使用场景,其中就提到"20种以上题型,可自由运用".从某种意义上来说,调查就是由一系列的问题(类型)所组成,所以 ...

  2. 预定义类型未定义或导入_【小程序】商家来看看,常见的微信小程序类型有哪些?...

    水精灵商务互动(周末版) 互动开始时间:2020年11月22日0时 克拉玛依人 关注本公众号,及时参与互动!(红包奖励来袭!) 随着小程序的数量越来越多,一些还没有开发过小程序的企业.商家再也做不住了 ...

  3. c语言中未定义标识符,未定义标识符

    源自:4-5 C++新特性以及输入输出演示 未定义标识符 代码: #include "iostream" #include "stdlib.h" #includ ...

  4. php 未定义偏移,未定义的偏移PHP错误,尝试从文件读取

    我得到一个"未定义的偏移" PHP错误,并没有得到它的原因.我只是个初学者. Notice: Undefined offset: 3 on line 58 这是我正在使用的代码: ...

  5. ArcEngine开发中“错误类型****未定义构造函数”

    问题 在ArcEngine开发的时候,在编译时,发现出现这样的错误,出错的地方在实例化一个对象的时候. 比如: ISpatialReference a = new UnknownCoordinateS ...

  6. 使用proxy来调用未定义的属性方法

    当我们调用一个函数的未定义属性时会报错.那么可以使用一个proxy拦截,并且传入dispatch函数,来使当调用未定义属性时,执行dispatch函数. 原理: 创建一个实例:function cre ...

  7. 是否有标准函数来检查 JavaScript 中的 null、未定义或空白变量?

    问: 是否有一个通用的 JavaScript 函数来检查变量是否具有值并确保它不是 undefined 或 null?我有这段代码,但我不确定它是否涵盖所有情况: function isEmpty(v ...

  8. 预定义的类型“System.Object”未定义或未导入

    打开一个以前的程序 ,发现报这个错误.检查了程序,发现程序的引用 System 不见了 ,尝试 引用失败.. 查了有人说重新建立 Sln文件有用.. 一头雾水,随后 尝试操作 ,程序有用了 具体步骤: ...

  9. ie9无法获取未定义或 null 引用的属性“indexof”_前端JS基础篇(二)JS基本数据类型和引用数据类型及检测数据类型方法...

    JS中的数据类型 (一).基本数据类型(值类型) 1.number:数字 -12.12.5.-12.5 0这些数字都是number: js中增加了一个number类型的数据:'NaN' typeof ...

  10. java方法未定义类型_java - 方法未定义为类 - SO中文参考 - www.soinside.com

    我试图在eclipse中运行一个使用名为getArea(一个对象)的方法的代码.我目前收到错误说 对于getArea(ComparableRectangle)类型,方法RectangleTest未定义 ...

最新文章

  1. Python每日一练0023
  2. [Eclipse]GEF入门系列(七、XYLayout和展开/折叠功能)
  3. java中 2017 1等于_java 2017秋招1
  4. 修改 Angular Component 构造函数参数被认为是 breaking change
  5. 实例56:python
  6. Macosx 安装 ionic 成功教程
  7. typescript箭头函数参数_Typescript 入门基础篇(一)
  8. c语言中c4700在哪个位置,C语言单链表问题。。高手来啊warning C4700
  9. Axure高保真保险行业后台管理原型+微保CRM客户管理+保险产品附加险、费率+web端企业crm后台管理原型+渠道管理、会员管理
  10. 如何修改layui数据表格绑定的开关事件?
  11. 如何禁用 macOS 更新通知?
  12. python打印字符串所在行_python打印文件中某个字符串的前几行
  13. (转) Playing FPS games with deep reinforcement learning
  14. 使用ASP.NET Global.asax 文件(转)
  15. 【2019南京icpc网络赛 I】Washing clothes【李超树】
  16. matlab 文本分析工具,MATLAB,Simulink. - Text Analytics , 文本分析工具箱-鈦思科技
  17. 电信光猫F652破解经验谈
  18. linux命令之hostid
  19. 青岛市中小学学籍系统服务器,全国各省市中小学学籍网登录入口
  20. linux格式化磁盘fdisk,linux下使用fdisk工具为磁盘分区格式化

热门文章

  1. 最简单的《域中隔离用户的FTP站点》详解
  2. linux 初步学习
  3. 春节咋过?韩上班族平均休4天 计划花费2756元人民币
  4. Vue 组件库 (一)
  5. window下安装sonar
  6. 自动化运维工具puppet的使用
  7. 使用 Travis 自动部署 Hexo 到 Github 与 自己的服务器
  8. Spring MVC深入讲解
  9. 调试内核Ubuntu 搭建嵌入式开发环境-续
  10. CCNA学习要点,希望能对初学者有帮助