Masimaro's blog - 从0自制解释器
type: channel
link: https://www.masimaro.top/category/make_interpreter_by_yourself/
description: 从0开始自制一个自己的C语言基础解释器
items: 从0开始自制解释器——重构代码
items: 从0开始自制解释器——添加对括号的支持
items: 从0开始自制解释器——添加对乘除法的支持
items: 从0开始自制解释器——实现多个整数的加减法
items: 从0开始自制解释器——实现多位整数的加减法计算器
items: 从0开始自制解释器——实现简单的加法计算器
items: 从0开始自制解释器——综述

从0开始自制解释器——重构代码
type: item
date: 2023-04-18T13:49:00+08:00
description: 在上一篇文章中，完成了对括号的支持，这样整个程序就可以解析普通的算术表达式了。但是在解析两个括号的过程中发现有大量的地方需要进行索引的回退操作，索引的操作应该保证能得到争取的token，这个步骤应该放在词法分析的阶段，如果在语法分析阶段还要考虑下层词法分析的过程，就显得有些复杂了。而且随着后续支持的符号越来越多，可能又得在大量的地方进行这种索引变更的操作，代码将难以理解和维护。因此这里先停下来进行一次代码的重构。基本架构这里的代码我按照教程里面的结构进行组织。将按照程序的逻辑分为3层，最底层负责操作字符串的索引保证下次获取token的时候索引能在正确的位置。第二层是词法分析部分，负责给字符串的每个部分都打上对应的token。第三个部分是语法分析的部分，它负责解析之前设计的BNF范式，并计算对应的结果。详细的代码上面给出模块划分的概要可能没怎么说清楚，下面将通过代码来进行详细的说明。Token 模块为了支持这个设计，首先变更一下全局变量的定义，现在定义的全局变量如下所示extern Token g_currentToken; //当前token extern int g_nPosition; //当前字符索引的位置 extern char g_currentChar; //当前字符串之前通过 get_next_char() 来返回当前指向的token并变更索引的时候发现我们在任何时候想获取当前指向的字符时永远要变更索引，这样就不得不考虑在某些时候要进行索引的回退。比如在解析整数退出的时候，此时当前字符已经指向下一个字符了，但是我们在接下来解析其他符号的时候调用 get_next_char() 导致索引多增加了一个。这种情况经常出现，因此这里使用全局变量保存当前字符，只在需要进行索引增加的时候进行增加。另外我们不希望上层来直接操作这个索引，因此在最底层的Token模块提供一个名为 advance() 的函数用于将索引加一，并获取之后的字符。它的定义如下void advance() { g_nPosition++; // 如果到达字符串尾部，索引不再增加 if (g_nPosition >= strlen(g_pszUserBuf)) { g_currentChar = '\0'; } else { g_currentChar = g_pszUserBuf[g_nPosition]; } }这样在对应需要用到当前字符的位置就不再使用 get_next_char() , 而是改用全局变量 g_currentChar。例如现在的 skip_whitespace 函数现在的定义如下void skip_whitespace() { while (is_space(g_currentChar)) { advance(); } }这样我们在获取下一个token的时候只在必要的时候进行索引的递增。lex 模块由于打标签的工作交个底层的Token模块了，该模块主要用来实现词法分析的功能，也就是给各个部分打上标签，根据之前Token部分提供的接口，需要对 get_next_token 函数进行修改。bool get_next_token() { dyncstring_reset(&g_currentToken.value); while (g_currentChar != '\0') { if (is_digit(g_currentChar)) { g_currentToken.type = CINT; parser_number(&g_currentToken.value); return true; } else if (is_space(g_currentChar)) { skip_whitespace(); } else { switch (g_currentChar) { case '+': g_currentToken.type = PLUS; dyncstring_catch(&g_currentToken.value, '+'); advance(); break; case '-': g_currentToken.type = MINUS; dyncstring_catch(&g_currentToken.value, '-'); advance(); break; case '*': g_currentToken.type = DIV; dyncstring_catch(&g_currentToken.value, '*'); advance(); break; case '/': g_currentToken.type = MUL; dyncstring_catch(&g_currentToken.value, '/'); ad…

从0开始自制解释器——添加对括号的支持
type: item
date: 2023-03-24T17:50:00+08:00
description: 在上一篇我们添加了对乘除法的支持，也介绍了BNF范式，并且针对当前的算术表达式写出了对应的范式，同时根据范式给出相应的代码实现。这篇我们将继续为算数表达式添加对括号的支持。对应的BNF 范式在上一篇我们给出了乘除法对应的范式<expr>::=<term>{(PLUS|MINUS)<term>} <term>::=<factor>{(DIV|MUL)<factor>} <factor>::={(0|1|2|3|4|5|6|7|8|9)}针对乘除法的优先级比加减法高，我们的做法是将乘除法单独作为一个部分，然后在最外层表达式中只处理加减法。基于这种思路，我们来看如何处理括号的问题。例如下面的算数表达式((1+2)*3+4) - (5 - 6 / 3)这里我们直接给出对应的文法，然后再来分析一下该如何由这个文法得到对应的表达式<expr>::=<term>{(PLUS|MINUS)<term>} <term>::=<factor>{(DIV|MUL)<factor>} <factor>::=({(0|1|2|3|4|5|6|7|8|9|)})|LPAREN<expr>RPAREN首先根据表达式，它应该由两个term来组成 expr = term - term接着看看两个term，它们并不是单纯的加法运算，所以两个term应该只有单纯的一个factor，也就是 expr = factor - factor因为最外层都有括号，所以再次展开 expr = (expr1) - (expr2)这时就又到了分析expr的过程了，左侧的expr最外层是一个加法，所以这里可以得到 expr1 = term + term右侧的expr 最外层是一个减法，也就是 expr2 = term - term结合最外层的表达式可以得到 expr = (term1 + term2) - (term3 - term4)term1 部分有一个乘法，所以它可以解析为 term1 = factor * factorterm2 部分就是单独的数字所以可以得到 term2 = factor，并且进一步得到 term2=4term3 部分就是单纯的数字，可以得到 term3 = factor，并且进一步得到 term3=5term4 部分有一个除法，所以它可以解析为 term3 = factor / factor此时整个表达式可以表示为 expr = (factor1 * factor2 + 4) - (5 - factor3 / factor4)factor1 本身也是一个括号，加表达式，所以它可以表示为 factor1 = (expr)factor2 是一个数字，所以它表示为 factor2 = 3factor3 是一个数字，所以它表示为 factor3 = 6factor4 是一个数字，所以它表示为 factor4 = 3此时表达式可以是 expr = ((expr1) * 3 + 4) - (5 - 6 / 3)此时再次分析这个 expr1 可以得到 expr1 = 1+2这个时候，整个表达式就出来了 expr = ((1+2) * 3 + 4) - (5 - 6 / 3)用图来表示大概可以表示如下代码实现有了范式，我们就可以按照范式来组织代码实现。首先我们先在 ETokenType 中添加针对括号的标签typedef enum e_TokenType { CINT = 0, //整数 PLUS, //加法 MINUS, //减法 DIV, //乘法 MUL, //除法 LPAREN, //左括号 RPAREN, //右括号 END_OF_FILE // 字符串末尾结束符号 }ETokenType;然后在 get_next_token 函数中添加对括号进行词法分析并打标签的功能bool get_next_token(LPTOKEN pToken) { char c = get_next_char(); dyncstring_reset(&pToken->value); if (is_digit(c)) { dyncstring_catch(&pToken->value, c); pToken->type = CINT; parser_number(&pToken->value); } else if(is_space(c)) { skip_whitespace(); return get_next_token(pToken); } else { switch (c) { case '+': pToken->type = PLUS; break; case '-': pToken->type = MINUS; break; case '*': pToken->type =…

从0开始自制解释器——添加对乘除法的支持
type: item
date: 2023-03-22T17:56:00+08:00
description: 在上一篇中，我们实现了对减法的支持，并且介绍了语法图。针对简单的语法进行描述，用语法图描述当然是没问题的。但是针对一些复杂的语法进行描述，如果每个部分都通过语法图来描述就显得有些繁琐了。这篇我们先介绍另一种描述语法的方式，并进一步介绍一些关于语法分析的知识。BNF范式与上下文无关文法巴科斯范式 以美国人巴科斯(Backus)和丹麦人诺尔(Naur)的名字命名的一种形式化的语法表示方法，用来描述语法的一种形式体系，是一种典型的元语言。又称巴科斯-诺尔形式(Backus-Naur form)。它不仅能严格地表示语法规则，而且所描述的语法是与上下文无关的。它以递归方式描述语言中的各种成分，凡遵守其规则的程序就可保证语法上的正确性。它具有语法简单，表示明确，便于语法分析和编译的特点。BNF表示语法规则的方式为：非终结符用尖括号括起。每条规则的左部是一个非终结符，右部是由非终结符和终结符组成的一个符号串，中间一般以“：：=”分开。具有相同左部的规则可以共用一个左部，各右部之间以直竖“|”隔开。所谓非终结符就是语言中某些抽象的概念不能直接出现在语言中的符号，终结符就是可以直接出现在语言中的符号。其实这些都是一些官话，初看起来只觉得拗口和难以理解，但是它的形式非常简单。它主要是用下面几个符号来表达含义使用<>来表示必须包含的部分使用[]来表示可选部分使用{}来表示可以重复0次或者无数次使用|来表示左右两边任选一部分，相当于OR使用::=来表示被定义为现在来给出具体的例子，我们都看过《西游记》，里面的取经4人组包括唐僧、孙悟空、猪八戒和沙僧。使用BNF范式进行定义，可以写成 <取经团队>::=<唐僧><孙悟空><猪八戒><沙僧>。我们再来举一个例子，我们知道一个文章由若干个段落组成、一个段落由若干个句子组成、一个句子由符合一定语法规则的汉字组成并且以句号作为结尾。我们简单的将句子的语法规则定义为主谓宾三个部分。而这里的主谓宾我们简单的用一些名词和动词来定义。因此这里的一系列结构可以定义为如下内容<文章>::={<段落>}<段落>::={<句子>}<句子>::=<主语><谓语><宾语>。<主语>::=人|狗|猫|天<谓语>::=吃|抓|下<宾语>::=饭|雨|肉|鱼根据这个表达式我们很容易的推出类似 人吃饭。、天下雨。、猫抓鱼。 这样的句子。相信到这里小伙伴应该明白BNF范式的一些基本概念和使用方式了。我们再来插入一个题外话，既然这里提到BNF范式是一种上下文无关文法，那什么是上下文、什么是上下文无关。先别着急了解概念，我们仍然通过例子来说明。在上述的句子的定义中，我们一共可以生成 4 * 3 * 4 = 48 种 结果，我们可以获得类似 人吃饭。、猫抓鱼。这种有意义的句子，也可能产生像天吃鱼。、人下雨 这种读起来感觉别扭的非正常语句。但是在上下文无关的语法中，主语宾语和谓语的内容没有相互关联，也就是说谓语和宾语的产生与主语无关。那上下文有关的文法呢？这里为了产生一些有意义的句子，我们给它加上一些限定。例如人后面只能接 吃 抓作为谓语、而当吃作为谓语时只能将 饭、肉、鱼作为宾语。针对这种需求，我们可以进行如下定义<句子>::=<主语><谓语><宾语>。<主语>::=人|狗|猫|天人<谓语>::=人(吃|抓)吃<宾语>::=吃(饭｜肉|鱼)这样我们对这个产生式进行了一些限定，当主语是人的时候，谓语只能产生吃和抓这样的宾语。这种情况下的描述就被称之为上下文有关。上下文无关我自己的理解就是后续表达式的产生不依赖前面已产生的内容。而上下文有关的含义则与之相法。这个上下文就跟我们这么多年阅读理解题里面写的“请根据上下文来理解某个词表达了作者怎样的心情”这里的上下文类似。当然更加规范的说法就是，在应用一个产生式进行推导时，前后已经推导出的部分结果就是上下文。上下文无关就是只要文法的定义里面有一个定义，不管前面的产生串是什么都可以应用相应的产生推导后面的内容。代码编写上面的定义只是开胃菜，希望通过上面的描述，小伙伴能够理解BNF范式的应用，至于上下文无关和上下文有关。这些暂时不用考虑，毕竟我们目前还是在做上下文无关文法相关的内容。这里我们要支持乘法和除法，首先要做的就是在 ETokenType 结构中添加对乘法和除法相关的定义typedef enum e_TokenType { CINT = 0, //整数 PLUS, //加法 MINUS, //减法 DIV, //乘法 MUL, //除法 END_OF_FILE // 字符串末尾结束符号 }ETokenType;接着在 get_next_token和 get_oper() 函数中添加对这两个运算符的支持// get_next_token else if (c == '*') { pToken->type …

从0开始自制解释器——实现多个整数的加减法
type: item
date: 2023-03-14T09:31:00+08:00
description: 在上一篇我们实现了一个可以计算两个多位整数加减法的计算器。本章我们继续来给这个计算器添加功能，这次要给它添加可以连续计算多个整数相加减的功能。例如我们可以计算 1 + 2 + 3 这样的表达式。语法图在正式写代码之前让我们先来学习一下一些基本的理论知识。这次要介绍的理论是语法图。什么是语法图呢？语法图是编程语言语法语法规则的图形表示。它体现了词法分析的运行规则。语法图直观的展示了在编程语言中哪些语句是符合语法的，哪些是不符合语法规范的。语法图的阅读非常容易，它类似于程序的流程图，只要顺着箭头指向的路径来读即可。与程序流程图类似，语法图中有些路径表示选择，有些表示循环。我们试着来读一下下面的语法图这张语法图表示的含义是，一个术语(term) 可选的跟上一个加号或者减号，而后面又需要跟上另一个术语。接着又可以有选择的跟上另一个加号或者减号。但是加号或者减号后面必须跟上另一个术语。这里又提到另一个单词，term 它的中文意思是术语。似乎很难用其他文字来解释何为术语。你只需要知道在这里它代表的是一个整数，它并不影响我们阅读这个语法图代码展示在上一篇中我们提到，将Token流识别为对应结构的过程被称之为词法分析，我们代码中的词法分析的实现主要在函数 expr 中。在这个函数中我们主要实现了词法分析以及最后的解释执行。我们按照语法图修改一下词法分析的代码我们先给出下面的伪代码获取第一个整数作为计算结果保存 while(解析到最后一个字符) { 获取操作符(+/-) switch(操作符) { case +: 获取下一个整数，如果不是整数则退出并报错 与结果相加 break; case -: 获取下一个整数，如果不是整数则退出并报错 与结果相减 break; } } 最终打印计算结果或者打印语法错误基于这个思路我们给出具体的实现代码int expr() { bool bRet = false; int result = get_term(&bRet); int bEOF = false; do { ETokenType oper = get_oper(&bRet); switch (oper) { case PLUS: { int num = get_term(&bRet); if(bRet) result += num; } break; case MINUS: { int num = get_term(&bRet); if(bRet) result -= num; } break; case END_OF_FILE: printf("%d\n", result); bEOF = true; break; default: bRet = false; break; } } while (bRet && !bEOF); if (!bRet) { printf("Syntax Error!\n"); } }这里为了便于理解，我将获取整数和操作符的模块又进行了一次封装，提供了两个函数分别是 get_term() 和 get_oper()。它们的代码如下int get_term(bool *pRet) { Token token = { 0 }; dyncstring_init(&token.value, DEFAULT_BUFFER_SIZE); int value = 0; if (get_next_token(&token) && token.type == CINT) { value = atoi(token.value.pszBuf); if (pRet) *pRet = true; } else { if (pRet) *pRet = false; } dyncstring_free(&token.value); return value; }ETokenType get_oper(bool* pRet) { Token token = { 0 }; dyncstring_init(&token.value, DEFAULT_BUFFER_SIZE); int oper = 0; if (get_next_token(&token) && (token.type == PLUS || token.type == MINUS)) { oper = token.type; if (pRet) *pRet = true; } else if (token.type == END_OF_FILE) { oper = END_OF_FILE; if (pRet) *pRet = true; } else { oper = -1; if (pRet) *pRet = false; } dyncstring_free(&token.value); …

从0开始自制解释器——实现多位整数的加减法计算器
type: item
date: 2023-03-08T16:53:00+08:00
description: 上一篇我们实现了一个简单的加法计算器，并且了解了基本的词法分析、词法分析器的概念。本篇我们将要对之前实现的加法计算器进行扩展，我们为它添加以下几个功能计算减法能自动识别并跳过空白字符不再局限于单个整数，而是能计算多位整数提供一些工具函数首先为了支持减法，我们需要重新定义一下TokenType这个类型，也就是需要给 - 定义一个标志。现在我们的TokenType的定义如下typedef enum e_TokenType { CINT = 0, PLUS, MINUS, END_OF_FILE }ETokenType;由于需要支持多个整数，所以我们也不知道最终会有多少个字符，因此我们提供一个END_OF_FILE 表示我们访问到了最后一个字符，此时应该退出词法分析的过程。另外因为整数个数不再确定，我们也就不能按照之前的提供一个固定大小的数组。虽然可以提供一个足够大的空间来作为存储数字的缓冲，但是数字少了会浪费空间。而且考虑到之后要支持自定义变量和函数，采用固定长度缓冲的方式就很难找到合适的大小，太大显得浪费空间，太小有时候无法容纳得下用户定义的变量和函数名。因此这里我们采用动态长度的字符缓冲来保存。我们提供一个DyncString 的结构来保存这些内容#define DEFAULT_BUFFER_SIZE 16 // 动态字符串结构，用于保存任意长度的字符串 typedef struct DyncString { int nLength; // 字符长度 int capacity; //实际分配的空间大小 char* pszBuf; //保存字符串的缓冲 }DyncString, *LPDyncString; // 动态字符串初始化 // str: 被初始化的字符串 // size: 初始化字符串缓冲的大小，如果给0则按照默认大小分配空间 void dyncstring_init(LPDyncString str, int size); // 动态字符串空间释放 void dyncstring_free(LPDyncString str); //重分配动态字符串大小 void dyncstring_resize(LPDyncString str, int newSize); //往动态字符串中添加字符 void dyncstring_catch(LPDyncString str, char c); // 重置动态数组 void dyncstring_reset(LPDyncString str);它们的实现如下/*----------------------------动态数组的操作函数-------------------------------*/ void dyncstring_init(LPDyncString str, int size) { if (NULL == str) return; if (size == 0) str->capacity = DEFAULT_BUFFER_SIZE; else str->capacity = size; str->nLength = 0; str->pszBuf = (char*)malloc(sizeof(char) * str->capacity); if (NULL == str->pszBuf) { error("分配内存失败\n"); } memset(str->pszBuf, 0x00, sizeof(char) * str->capacity); } void dyncstring_free(LPDyncString str) { if (NULL == str) return; str->capacity = 0; str->nLength = 0; if (str->pszBuf == NULL) return; free(str->pszBuf); } void dyncstring_resize(LPDyncString str, int newSize) { int size = str->capacity; for (; size < newSize; size = size * 2); char* pszStr = (char*)realloc(str->pszBuf, size); str->capacity = size; str->pszBuf = pszStr; } void dyncstring_catch(LPDyncString str, char c) { if (str->capacity == str->nLength + 1) { dyncstring_resize(str, str->capacity + 1); } str-…

从0开始自制解释器——实现简单的加法计算器
type: item
date: 2023-03-07T19:38:00+08:00
description: 为什么要学习编译器和解释器呢？文中的作者给出的答案有下面几个：为了深入理解计算机是如何工作的：一个显而易见的道理就是，如果你不懂编译器和解释器是如何工作的那么你就不明白计算机是如何工作的编译器和解释器用到的一些原理和编程技巧以及算法在其他地方也可以用到。学习编译器和解释器能够学到并强化这些技巧的运用为了方便日后能编写自己的编程语言或者专用领域的特殊语言接下来我们就从0开始一步一步的构建自己的解释器。跟着教程先制作一个简单的加法计算器，为了保证简单，这个加法计算器能够解析的表达式需要满足下面几点：目前只支持加法运算目前只支持两个10以内的整数的计算表达式之间不能有空格只能计算一次加法举一个例子来说，它可以计算诸如"1+2"、"5+6" 这样的表达式，但是不能计算像 "11+20"(必须是10以内)、"1.1+2"(需要两个数都是整数)、"1 + 2"(中间不能有空格)、"1+2+3"(只能计算一次加法)有了这些限制，我们很容易就能实现出来。实现的算法假设我们要计算表达式 5+6。这里主要的步骤是通过字符串保存表达式，然后通过索引依次访问每个字符，分别找到两个整数和加法运算符，最后实现两个整数相加的操作。第一步，我们的索引在表达式字符串的开始位置，解析得到当前位置的字符是一个整数，我们给它打上标记，类型为整形，值为5。第二步，索引向前推进，解析当前位置的字符是一个+。还是给它打上标记，类型为plus，值为+。第三步，索引继续前进，解析到当前位置的字符是一个整数，我们给它打上标记，类型为整形，值为6最后一步，根据得到的两个整数以及要执行的算术运算，我们将两个数直接进行相加得到最终结果具体的代码首先我们定义这个标记的类型，目前支持整数以及加法的标记typedef enum e_TokenType { CINT = 0, //整型 PLUS //加法运算符 }ETokenType; // 这里因为只支持10以内的整数，所以表示计算数字的字符只有一个，加上字符串最后的结束标记，字符数组只需要两个即可 typedef struct Token { ETokenType type; //类型 char value[2]; //值 }Token, *LPTOKEN;接着定义一些全局变量来保存算术运算的表达式和当前指针的索引char* g_pszUserBuf = NULL; char* g_pPosition = NULL;接着我们定义一个函数来模拟上述说到的不断解析每一个字符的过程bool get_next_token(LPTOKEN pToken) { char* sz = g_pPosition; g_pPosition++; pToken->value[0] = '\0'; if (*sz >= '0' && *sz <= '9') { pToken->type = CINT; pToken->value[0] = *sz; return true; } else if (*sz == '+') { pToken->type = PLUS; pToken->value[0] = *sz; return true; } else { pToken->value[0] = '\0'; return false; } }最后我们定义一个函数来执行获取每个标记并最终计算结果的操作int expr() { int val1 = 0, val2 = 0; Token token = { 0 }; if (get_next_token(&token) && token.type == CINT) { val1 = atoi(token.value); } else { printf("首个字符必须是整数"); return -1; } if (get_next_token(&token) && token.type == PLUS) { } else { printf("第二个字符必须是操作符，并且当前只支持 + 运算"); return -1; } if (get_next_token(&token) && token.type == CINT) { val2 = atoi(token.value); } printf("%d+%d=%d\n", val1, val2, val1 + val2); }在main函数里面我们只需要建立一个缓冲来保存字符，并且在循环中不断等待用户输入，完成解析并输出结果即可// 重制当前解析环境 void reset() { memset(g_pszUserBuf, 0x00, 16 * sizeof(char)); scanf_s("%s", g_pszUserBuf); g_pPosition = g_pszUse…

从0开始自制解释器——综述
type: item
date: 2023-03-04T20:41:00+08:00
description: 作为一个程序员，自制自己的编译器一直是一个梦想。之前也曾为了这个梦想学习过类似龙书、虎书这种大部头的书，但是光看理论总有一些云里雾里的感觉。看完只觉得脑袋昏昏沉沉并没有觉得有多少长进。当初看过《疯狂的程序员》这本书，书里说，真正能学会编译原理并不是靠看各种书然后通过相关考试，而是有一天你的领导找到你对你说：“小X啊，你是我们公司技术能力最强的人，咱们现在用的编译器性能有点跟不上，要不你看看能不能改进一下”。所以想要学习编译原理相关的知识首先要做的还是实践——实现一个自己的编译器。之前也看过类似的教你如何自制编译器，但是他们有一个共同的问题就是在很大程度上都借助第三方工具，隐藏了一些底层的细节。我希望的是每一行代码都是自己的完成的。所以一直怀揣着这个梦想直到最近我找到了一篇教程。一起写一个简单的编译器——魔力Python。这篇教程是实用Python完成的，但是这里我不打算使用Python，我打算实用最纯粹的C 语言来完成这个任务，我考虑使用C主要基于以下几个原因:Python 有一些封装的细节，不方便全方位的展示相关算法。原教程使用的就是Python，还用一样的话思路会受到教程的影响，要真正的理解需要自己一行行的敲代码，最好的方式就是用另一种语言来实现同样的算法现在市面上大多数都是用c来实现编译器，如果后续想要更近一步学习编译原理可以考虑在我完成的这版中很方便的加入一些新学的知识点自己有使用C的能力，而且用C写编译器自带装B属性基于以上理由，我准备开始跟着教程使用C来实现自己的解释器。这并不是一篇教程什么的，更多的是作为一篇实践笔记。而且根据我之前写的Vim专栏的经验来说，将它已专栏的形式发布出来之后鸽的可能性更小，更有动力来完成它。当然如果各位能从专栏中学到什么那就更好了。总之后面让我们一起进入学习编译原理的路程吧