探索类型系统的底层 - 本身实现一个 TypeScript

这篇文章包含两个部分:node

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

  • 语法 vs 语义
  • 什么是 AST?
  • 编译器的类型
  • 语言编译器是作什么的?
  • 语言编译器是如何工做的?
  • 类型系统编译器职责
  • 高级类型检查器的功能

B 部分:构建咱们本身的类型系统编译器git

  • 解析器
  • 检查器
  • 运行咱们的编译器
  • 咱们遗漏了什么?

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

语法 vs 语义

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

语法 - Syntax

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

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

var foo: number = "not a number";

语义 - Semantics

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

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

接下来是 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 BundlerSource 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 实际上引入了 Symbolsinterface)的概念,这些命名声明将 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 annotation
          type: "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 on
  return 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 annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 参数类型不一样于场景 1
            type: "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 on
  return 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 on
  return 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":
        // 若是调用时传入的是数字,返回 true
        return 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;
            // 没有匹配,将错误信息存入 errors
            if (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 valid
          const isValid = typeChecks.annotationCheck(argType);
          if (!isValid) {
            errors.push(
              `Type "${argType}" for argument "${arg.name}" does not exist`
            );
          }
        }
      });

      // Process function "block" code here
      stnmt.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 declaration
      if (!declationForName) {
        errors.push(`Function "${functionCalled}" does not exist`);
        return;
      }

      // Array of arg-to-type. e.g. 0 = NumberTypeAnnotation
      const 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 here
        const isValid = typeChecks.expression(
          argTypeMap[index], // declaration details
          arg // caller details
        );

        if (!isValid) {
          const annotatedType = ANNOTATED_TYPES[declarationType];
          // Show values to user, more explanatory than types
          errors.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...

相关文章
相关标签/搜索