我也写了个疫情传播仿真程序
在上一节介绍了语法树的结构,本节则介绍如何解析标记组成语法树。
对应的源码位于 src/compiler/parser.ts。
入口函数
要解析一份源码,输入当然是源码内容(字符串),同时还提供路径(用于报错)、语言版本(比如ES3 和 ES5 在有些细节不同)。
createSourceFile 是负责将源码解析为语法树的入口函数,用户可以直接调用:比如 ts.createSourceFile(‘<stdio>’, ‘var xld;’)。
export function createSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, setParentNodes = false, scriptKind?: ScriptKind): SourceFile { performance.mark("beforeParse"); let result: SourceFile; perfLogger.logStartParseSourceFile(fileName); if (languageVersion === ScriptTarget.JSON) { result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, ScriptKind.JSON); } else { result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, scriptKind); } perfLogger.logStopParseSourceFile(); performance.mark("afterParse"); performance.measure("Parse", "beforeParse", "afterParse"); return result; }
入口函数内部除了某些性能测试代码,主要是调用 Parser.parseSourceFile 完成解析。
解析源文件对象
export function parseSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, syntaxCursor: IncrementalParser.SyntaxCursor | undefined, setParentNodes = false, scriptKind?: ScriptKind): SourceFile { scriptKind = ensureScriptKind(fileName, scriptKind); /* ...(略)... */ initializeState(sourceText, languageVersion, syntaxCursor, scriptKind); const result = parseSourceFileWorker(fileName, languageVersion, setParentNodes, scriptKind); clearState(); return result; } function initializeState(_sourceText: string, languageVersion: ScriptTarget, _syntaxCursor: IncrementalParser.SyntaxCursor | undefined, scriptKind: ScriptKind) { NodeConstructor = objectAllocator.getNodeConstructor(); TokenConstructor = objectAllocator.getTokenConstructor(); IdentifierConstructor = objectAllocator.getIdentifierConstructor(); SourceFileConstructor = objectAllocator.getSourceFileConstructor(); sourceText = _sourceText; syntaxCursor = _syntaxCursor; parseDiagnostics = []; parsingContext = 0; identifiers = createMap<string>(); identifierCount = 0; nodeCount = 0; switch (scriptKind) { case ScriptKind.JS: case ScriptKind.JSX: contextFlags = NodeFlags.JavaScriptFile; break; case ScriptKind.JSON: contextFlags = NodeFlags.JavaScriptFile | NodeFlags.JsonFile; break; default: contextFlags = NodeFlags.None; break; } parseErrorBeforeNextFinishedNode = false; // Initialize and prime the scanner before parsing the source elements. scanner.setText(sourceText); scanner.setOnError(scanError); scanner.setScriptTarget(languageVersion); scanner.setLanguageVariant(getLanguageVariant(scriptKind)); }
如果你仔细读了这段代码,你可能会有这些疑问:
1. NodeConstructor 等是什么
你可以直接将它看成 Node 类的构造函数,new NodeConstructor 和 new Node 是一回事。那为什么不直接用 new Node? 这是一种性能优化手段。TS 设计的目的是用于任何 JS 引擎,包括浏览器、Node.js、微软自己的 JS 引擎,而 Node 代表语法树节点,数目会非常多,TS 允许针对不同的环境使用不同的 Node 类型,以达到最节约内存的效果。
2. syntaxCursor 是什么
这是用于增量解析的对象,如果不执行增量解析,它是空的。增量解析是指如果之前已解析过一次源码,第二次解析时可以复用上次解析的结果,主要在编译器场景使用:编辑完源码后,源码要重新解析为语法树,如果通过增量解析,可以大幅减少解析次数。增量解析将在下一节中详细介绍。
3. identifiers 是什么
一般地,我们认为:源码中的单词都会用两次以上(变量名总会有定义和使用的时候,这里就有两次),如果将相同内容的字符串共用相同的引用,可以节约内存。identifiers 就保存了每个字符串内存的唯一引用。
4. parsingContext 是什么
用于指代当前解析所在的标记位,比如当前函数是否有 async,这样可以判断 await 是否合法。
5. parseErrorBeforeNextFinishedNode 是什么
每个语法树节点,都通过 createNode 创建,然后结束时会调用 finishNode,如果在解析一个语法树节点时出现错误(可能是词法扫描错误、也可能是语法错误),都会把 parseErrorBeforeNextFinishedNode 改成 true,在 finishNode 中会判断这个变量,然后标记这个语法树节点存在语法错误。TypeScript 比其它语法解析器强大的地方在于碰到语法错误后并不会终止解析,而是尝试修复源码。(因为在编辑器环境,不可能因为存在错误就停止自动补全)。这里标记节点语法错误,是为了下次增量解析时禁止重用此节点。
解析过程
虽然这篇文章叫 TypeScript 源码解读,但其实主要是介绍编译器的实现原理,知道了这些原理,无论什么语言的编译器你都能弄明白,反过来如果你之前没有什么基础想要自己读懂 TypeScript,那是很难的。源码就像水果,你需要自己剥;这篇文章就像果汁,营养吸收地更快。插图是展示原理的最好方式,因此文中会包含大量的插图,如果你现在读的这个网页是纯文字,一张插图都没有,那么这个网站就是盗版侵权的,请重新百度。原版都是有插图的,插图可以让你快速理解原理!这类文章目前不止中文版的稀缺,英文版的同样稀缺,毕竟了解编译原理、且真正能开发出语言的人是非常少的。有兴趣读这些文章的也绝不是只知道搬砖赚钱的菜鸟,请支持原版!
解析器每次读取一个标记,并根据这个标记判断接下来是什么语法,比如碰到 if 就知道是 if 语句,碰到 var 知道是变量声明。
当发现 if 之后,根据 if 语句的定义,接下来会强制读取一个 “(” 标记,如果读不到就报错:语法错误,缺少“(”。读完 “(” 后解析一个表达式,然后再解析一个“)”,然后再解析一个语句,如果这时接下来发现一个 else,就继续读一个语句,否则直接终止,然后重新判断下一个标记的语法。
if 语句的语法定义是这样的:
IfStatement: if ( Expression ) Statement if ( Expression ) Statement else Statement
这个定义的意思是:if 语句(IfStatement)有两种语法,当然无论哪种,开头都是 if ( Expression ) Statement
为什么是这样定义的呢,这是因为JS是遵守ECMA-262规范的,而ECMA-262规范就像一种协议,规定了 if 语句要怎么定义。
ECMA-262 规范也有很多版本,熟悉的ES3,ES5,ES6 这些其实就是这个规范的版本。ES10的版本可以在这里查看:http://www.ecma-international.org/ecma-262/10.0/index.html#sec-grammar-summary
代码实现
源文件由语句组成,首先读取下一个标记(nextToken);然后解析语句列表(parseList, parseStatement)
function parseSourceFileWorker(fileName: string, languageVersion: ScriptTarget, setParentNodes: boolean, scriptKind: ScriptKind): SourceFile { const isDeclarationFile = isDeclarationFileName(fileName); sourceFile = createSourceFile(fileName, languageVersion, scriptKind, isDeclarationFile); sourceFile.flags = contextFlags; // Prime the scanner. nextToken(); // A member of ReadonlyArray<T> isn't assignable to a member of T[] (and prevents a direct cast) - but this is where we set up those members so they can be readonly in the future processCommentPragmas(sourceFile as {} as PragmaContext, sourceText); processPragmasIntoFields(sourceFile as {} as PragmaContext, reportPragmaDiagnostic); sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement); Debug.assert(token() === SyntaxKind.EndOfFileToken); sourceFile.endOfFileToken = addJSDocComment(parseTokenNode()); setExternalModuleIndicator(sourceFile); sourceFile.nodeCount = nodeCount; sourceFile.identifierCount = identifierCount; sourceFile.identifiers = identifiers; sourceFile.parseDiagnostics = parseDiagnostics; if (setParentNodes) { fixupParentReferences(sourceFile); } return sourceFile; function reportPragmaDiagnostic(pos: number, end: number, diagnostic: DiagnosticMessage) { parseDiagnostics.push(createFileDiagnostic(sourceFile, pos, end, diagnostic)); } }
解析一个语句:
function parseStatement(): Statement { switch (token()) { case SyntaxKind.SemicolonToken: return parseEmptyStatement(); case SyntaxKind.OpenBraceToken: return parseBlock(/*ignoreMissingOpenBrace*/ false); case SyntaxKind.VarKeyword: return parseVariableStatement(<VariableStatement>createNodeWithJSDoc(SyntaxKind.VariableDeclaration)); // ...(略) } }
规则很简单:
先看现在标记是什么,比如是 var,说明是一个 var 语句,那就继续解析 var 语句:
function parseVariableStatement(node: VariableStatement): VariableStatement { node.kind = SyntaxKind.VariableStatement; node.declarationList = parseVariableDeclarationList(/*inForStatementInitializer*/ false); parseSemicolon(); return finishNode(node); }
var 语句的解析过程为先解析一个声明列表,然后解析分号(parseSemicolon)
再看一个 while 语句的解析:
function parseWhileStatement(): WhileStatement { const node = <WhileStatement>createNode(SyntaxKind.WhileStatement); parseExpected(SyntaxKind.WhileKeyword); // while parseExpected(SyntaxKind.OpenParenToken); // ( node.expression = allowInAnd(parseExpression); // *Expession* parseExpected(SyntaxKind.CloseParenToken); // ) node.statement = parseStatement(); // *Statement* return finishNode(node); }
所谓语法解析,就是把每个不同的语法都这样解析一次,然后得到语法树。
其中,最复杂的应该是解析列表(parseList):
// Parses a list of elements function parseList<T extends Node>(kind: ParsingContext, parseElement: () => T): NodeArray<T> { const saveParsingContext = parsingContext; parsingContext |= 1 << kind; const list = []; const listPos = getNodePos(); while (!isListTerminator(kind)) { if (isListElement(kind, /*inErrorRecovery*/ false)) { const element = parseListElement(kind, parseElement); list.push(element); continue; } if (abortParsingListOrMoveToNextToken(kind)) { break; } } parsingContext = saveParsingContext; return createNodeArray(list, listPos); }
parseList 的核心就是一个循环,只要列表没有结束,就一直解析同一种语法。
比如解析参数列表,碰到“)”表示列表结束,否则一直解析“参数”;比如解析数组表达式,碰到“]”结束。
如果理论接下来应该解析参数时,但下一个标记又不是参数,则会出现语法错误,但接下来应该解析解析参数,还是不再继续参数列表,这时候用 abortParsingListOrMoveToNextToken 判断。 其中,kind: ParsingContext 用于区分不同的列表(是参数,还是数组?或者别的?)
列表结束
// True if positioned at a list terminator
function isListTerminator(kind: ParsingContext): boolean {
if (token() === SyntaxKind.EndOfFileToken) { // Being at the end of the file ends all lists. return true; } switch (kind) { case ParsingContext.BlockStatements: case ParsingContext.SwitchClauses: case ParsingContext.TypeMembers: return token() === SyntaxKind.CloseBraceToken; // ...(略) } }
总结:对于有括号的标记,只有碰到右半括号,才能停止解析,其它的比如继承列表(extends A, B, C) 碰到 “{” 就结束。
解析元素
function parseListElement<T extends Node>(parsingContext: ParsingContext, parseElement: () => T): T { const node = currentNode(parsingContext); if (node) { return <T>consumeNode(node); } return parseElement(); }
这里本质是使用了 parseElement,其它代码是为了增量解析(后面详解)
继续列表?
function abortParsingListOrMoveToNextToken(kind: ParsingContext) { parseErrorAtCurrentToken(parsingContextErrors(kind)); if (isInSomeParsingContext()) { return true; } nextToken(); return false; }
// True if positioned at element or terminator of the current list or any enclosing list function isInSomeParsingContext(): boolean { for (let kind = 0; kind < ParsingContext.Count; kind++) { if (parsingContext & (1 << kind)) { // 只要是任意一种上下文 if (isListElement(kind, /*inErrorRecovery*/ true) || isListTerminator(kind)) { return true; } } } return false; }
总结:如果接下来的标记是合法的元素,就继续解析,此时解析器认为用户只是忘打逗号之类的分隔符。如果不是说明这个列表根本就是有问题的,不再继续犯错。
痞子衡嵌入式:痞子衡嵌入式半月刊 第 1 期
上面重点介绍了 if 的语法,其它都大同小异,就不再介绍。
现在你应该知道语法树产生的大致过程了,如果仍不懂的,可在此处停顿往回复习,并对照源码,加以理解。
语法上下文
有些语法的使用是有要求的,比如 await 只在 async 函数内部才作关键字。
源码中用闭包内全局的变量存储这些信息。思路是:先设置允许 await 标记位,然后解析表达式(这时标记位已设置成允许 await),解析完成则清除标记位。
function doInAwaitContext<T>(func: () => T): T { return doInsideOfContext(NodeFlags.AwaitContext, func); } function doInsideOfContext<T>(context: NodeFlags, func: () => T): T { // contextFlagsToSet will contain only the context flags that // are not currently set that we need to temporarily enable. // We don't just blindly reset to the previous flags to ensure // that we do not mutate cached flags for the incremental // parser (ThisNodeHasError, ThisNodeOrAnySubNodesHasError, and // HasAggregatedChildData). const contextFlagsToSet = context & ~contextFlags; if (contextFlagsToSet) { // set the requested context flags setContextFlag(/*val*/ true, contextFlagsToSet); const result = func(); // reset the context flags we just set setContextFlag(/*val*/ false, contextFlagsToSet); return result; } // no need to do anything special as we are already in all of the requested contexts return func(); }
这也是为什么规范中每个语法名称后面都带了一个小括号的原因:表示此处的表达式是否包括 await 这样的意义。
IfStatement[Yield, Await, Return]: if ( Expression[+In, ?Yield, ?Await] ) Statement[?Yield, ?Await, ?Return] else Statement[?Yield, ?Await, ?Return] if ( Expression[+In, ?Yield, ?Await] ) Statement[?Yield, ?Await, ?Return]
其中,+In 表示允许 in 标记,?yield 表示 yield 标记保持不变,- in 表示禁止 in 标记。
通过上下文语法,下面这样的代码是不允许的:
for(var x = key in item; x; ) { }
(虽然 in 是本身也是可以直接使用的运算符,但不能用于 for 的初始值,否则按 for..in 解析。)
后瞻(lookahead)
上面举的例子,都是可以通过第一个标记就可以确定后面的语法(比如碰到 if 就按 if 语句处理)。那有没可能只看第一个标记无法确定之后的语法呢?
早期的 JS 版本是没有的(毕竟这样编译器做起来简单),但随着 JS 功能不断增加,就出现了这样的情况。
比如直接 x 是变量,如果后面有箭头, x =>,就成了参数。
这时需要用到语法后瞻,所谓的后瞻就是提前看一下后面的标记,然后决定。
/** Invokes the provided callback then unconditionally restores the parser to the state it * was in immediately prior to invoking the callback. The result of invoking the callback * is returned from this function. */ function lookAhead<T>(callback: () => T): T { return speculationHelper(callback, /*isLookAhead*/ true); } function speculationHelper<T>(callback: () => T, isLookAhead: boolean): T { // Keep track of the state we'll need to rollback to if lookahead fails (or if the // caller asked us to always reset our state). const saveToken = currentToken; const saveParseDiagnosticsLength = parseDiagnostics.length; const saveParseErrorBeforeNextFinishedNode = parseErrorBeforeNextFinishedNode; // Note: it is not actually necessary to save/restore the context flags here. That's // because the saving/restoring of these flags happens naturally through the recursive // descent nature of our parser. However, we still store this here just so we can // assert that invariant holds. const saveContextFlags = contextFlags; // If we're only looking ahead, then tell the scanner to only lookahead as well. // Otherwise, if we're actually speculatively parsing, then tell the scanner to do the // same. const result = isLookAhead ? scanner.lookAhead(callback) : scanner.tryScan(callback); Debug.assert(saveContextFlags === contextFlags); // If our callback returned something 'falsy' or we're just looking ahead, // then unconditionally restore us to where we were. if (!result || isLookAhead) { currentToken = saveToken; parseDiagnostics.length = saveParseDiagnosticsLength; parseErrorBeforeNextFinishedNode = saveParseErrorBeforeNextFinishedNode; } return result; }
说的比较简单,具体实现稍微麻烦:如果预览的时候发现标记错误咋办?所以需要先记住当前的错误信息,然后使用扫描器的预览功能读取之后的标记,之后完全恢复到之前的状态。
TypeScript 中有哪些语法要后瞻呢?
- <T> 可能是类型转换(<any>x)、箭头函数( <T> x => {})或 JSX (<T>X</T>)
- public 可能是修饰符(public class A {}),或变量(public++)
- type 在后面跟标识符时才是别名类型,否则作变量
- let 只有在后面跟标识符时才是变量声明,否则是变量,但 let let = 1 是不对的。
可以看到语法后瞻增加了编译器的复杂度,也浪费了一些性能。
虽然语法设计者尽量避免出现这样的后瞻,但还是有一些地方,因为兼容问题不得不采用这个方案。
语法歧义
/ 的歧义
之前提到的 / 可能是除号或正则表达式,在词法阶段还无法分析,但在语法解析阶段,因为已知道现在需要什么语法,可以正确地处理这个符号。
比如需要表达式的时候,碰到 /,因为 / 不能是表达式的开头,只能把 / 重新按正则表达式标记解析。如果在表达式后面碰到 /,那就作除号。
但也有些歧义是语法解析阶段都很难处理的。
< 的歧义
比如 call<number, any>(x),你可能觉得是调用 call 泛型函数(参数 x),但它也可以理解成: (call < number) , (any > (x))
所有支持泛型的C风格语言都有类似的问题,多数编译器的做法是:和你想的一样,按泛型看,毕竟一般人很少在 > 后面写括号。
在 TS 设计之初,<T>x 是表示类型转换的,这个设计源于 C。但后来为了支持 JSX,这个语法就和 JSX 彻底冲突了。
因此 TS 选择的方案是:引入 as 语法,<T>x 和 x as T 完全相同。同时引入 tsx 扩展名,在 tsx 中,<T> 当 JSX,在普通 ts,<T> 依然是类型转换(为兼容)。
插入分号
JS 一向允许省略分号,在需要解析分号的地方判断后面的标记是否是“}”,或包含空行。
function canParseSemicolon() { // If there's a real semicolon, then we can always parse it out. if (token() === SyntaxKind.SemicolonToken) { return true; } // We can parse out an optional semicolon in ASI cases in the following cases. return token() === SyntaxKind.CloseBraceToken || token() === SyntaxKind.EndOfFileToken || scanner.hasPrecedingLineBreak(); }
JSDoc
TS 为了尽可能兼容 JS,允许用户直接使用 JS + JSDoc 的方式备注类型,所以 JS 里的 JSDoc 注释也按源码的一部分解析。
/** @type {string} */ var x = 120
export function parseIsolatedJSDocComment(content: string, start: number | undefined, length: number | undefined): { jsDoc: JSDoc, diagnostics: Diagnostic[] } | undefined { initializeState(content, ScriptTarget.Latest, /*_syntaxCursor:*/ undefined, ScriptKind.JS); sourceFile = <SourceFile>{ languageVariant: LanguageVariant.Standard, text: content }; const jsDoc = doInsideOfContext(NodeFlags.None, () => parseJSDocCommentWorker(start, length)); const diagnostics = parseDiagnostics; clearState(); return jsDoc ? { jsDoc, diagnostics } : undefined; } export function parseJSDocComment(parent: HasJSDoc, start: number, length: number): JSDoc | undefined { const saveToken = currentToken; const saveParseDiagnosticsLength = parseDiagnostics.length; const saveParseErrorBeforeNextFinishedNode = parseErrorBeforeNextFinishedNode; const comment = doInsideOfContext(NodeFlags.None, () => parseJSDocCommentWorker(start, length)); if (comment) { comment.parent = parent; } if (contextFlags & NodeFlags.JavaScriptFile) { if (!sourceFile.jsDocDiagnostics) { sourceFile.jsDocDiagnostics = []; } sourceFile.jsDocDiagnostics.push(...parseDiagnostics); } currentToken = saveToken; parseDiagnostics.length = saveParseDiagnosticsLength; parseErrorBeforeNextFinishedNode = saveParseErrorBeforeNextFinishedNode; return comment; }
小结
本节介绍了语法解析,并提到了 TS 如何在碰到错误后继续解析。
下节将重点介绍增量解析。 #不定时更新#
时间有限,文章未校验,如果发现错误请指出。
【WPF学习】第三十二章 执行命令