探索類型系統的底層 - 本身實現一個 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...

相關文章
相關標籤/搜索