词法分析的最终目标是将输入字符流变成一个个符号,因此还需要引入一个新的结构:
/* datastruct.h */ struct Token { int line; // 所在行 AcceptType type; // 类型 char* image; // 像 };
其中type和image域是至关重要的,分别表示该符号的接受类型和"外貌",后者对于常数值、标识符很重要,而对于关键字和运算符,实际上知道其类型就行了。而所在行line域则是调试和报错时需要用到。
为了不使解析过程过于复杂,并且考虑到设计应该尽可能的模块化,实现中将产生符号和获取字符的部分抽象成为接口:
/* dfa.c */ int nextChar(void); // 获取下一个字符 struct Token* firstToken(void); // 获取第一个符号指针,这个特殊的调用可能会包含某些初始化 struct Token* nextToken(void); // 获取下一个符号指针 void eofToken(void); // 表示分析过程结束
步交替方法
如果输入仅仅是一个串的话,那么判别DFA是否接受它很简单。然而现在输入是一连串等待分割的符号,因此当分析过程产生错误时,不应该是立即报错,而应该把从出错开始的部分试图拼凑到后一个符号中,如输入
123.45+a
首先自动机会一直识别到"123.45",接下来看到"+",出错,这时就应该恢复最后一次成功接受的状态(即读入了"123.45"时),然后从"+"开始重新识别。这里涉及到两点,一是记录状态,另一是缓存字符。Jerry语言的词法很简单,实际上,只需要缓存一个字符就可以了,而相应地,状态只需要记录到上一状态即可(可以认为输入"123.45"和"123.45+"这两个字符串的状态是连续的两个状态,当下一状态出错时,恢复上一状态就可以了)。对于此,这样一些变量就可以对付这些记录方式了:
struct State* state[2]; int sw = 0; int character;
sw是一个转换变量,通过设置它来切换两个状态。 因为在出错时缓存了一个字符,所以当返回一个符号后并进入新一轮解析时,当前状态不应置为初始状态,而应该置为初始状态对该字符的跳转,因此循环体大致是这样的:
struct State* initial = initStates(); while(1) { state[sw] = initial->nextState[character]; state[1 ^ sw] = NULL; // 将缓存字符追加到当前符号的image后面 while(NULL != state[sw]) { character = nextChar(); if(EOF == character) { if(DENY == state[sw]->type) { // 报错 } else { token->type = state[sw]->type; } free(initial); eofToken(); return; } // 将当前字符追加到当前符号的image之后 state[1 ^ sw] = state[sw]->nextState[character]; sw ^= 1; // equivalent to "sw = 1 - sw;" } sw ^= 1; if(NULL == state[sw]) { // 报错 } else { token->type = state[sw]->type; token = nextToken(); } }
然而开始读入第一个字符时,没有这样缓存过程,因此需要把这样一个过程单独抽取出来,放到循环之外——循环开头之前。 此外还有一个小细节,就是查询关键字表来确定一个标识符一样的东西是不是关键字,简单的实现是这样的:
static char* RESV_WORD_LIST[] = { "", "else", "if", "while", "read", "write", "break", "int", "real", NULL }; static int foundAsResvWord(char* image) { int i = 1; for(; NULL != RESV_WORD_LIST[i]; ++i) { if(0 == strcmp(image, RESV_WORD_LIST[i])) { return i; } } return 0; }
看起来很暴力的搜索,当然可以实现得更好一点,比如二分一下。需要注意的是,这里的各个关键字的顺序需要跟接受类型枚举中各个对应量的顺序一致,而标识符对应的量要在这些量之前——这个函数的返回值是一个偏移值,而不是接受类型枚举。大致的过程就是这样的,下面给出一个较完整的过程:
/* dfa.c */ void tokenize(void) { struct State* state[2]; int sw = 0; int character; char* image; struct Token* token = firstToken(); struct State* initial = initStates(); character = nextChar(); // printf("--CHAR CODE-- %d %c", character, character); if(EOF == character) { exit(0); } /* 特别第一个读入的字符 */ while(1) { state[sw] = initial->nextState[character]; state[1 ^ sw] = NULL; image = token->image; *(image++) = character; while(NULL != state[sw]) { character = nextChar(); // printf("--CHAR CODE-- %d %c ", character, character); if(EOF == character) { if(DENY == state[sw]->type) { // 报错 } else { token->type = state[sw]->type; } *image = 0; free(initial); eofToken(); return; } *(image++) = (char)(character & 0xff); // *image = 0; printf("-- INFO -- %d %c %s\n", sw, character, token->image); state[1 ^ sw] = state[sw]->nextState[character]; sw ^= 1; // equivalent to "sw = 1 - sw;" } sw ^= 1; // printf("--RECONGIZED--\n"); if(NULL == state[sw]) { // 报错 } else { *(image - 1) = 0; token->type = state[sw]->type; if(IDENT == token->type) { token->type += foundAsResvWord(token->image); } token = nextToken(); } } }
步交替方法的局限归根结底,在于它仅能缓存一个字符和恢复到最近的一个状态。它在Jerry语言的词法分析构造中游刃有余并不表示它是一个通用的方法,假如有个3型文法接受长度为奇数,字符全为'a'的字符串或字符全为'b'的串,那么输入
aaaab
时,自动机读到第5个字符'b'时猛然发现前面有4个'a',因此不能接受,而需要向前回滚更多,对此,除非你构造一个如蜈蚣一样的步交替方法,否则词法分析无法继续。然而这种怪异的文法究竟有没有可能在实践中运用,可能永远是一个谜……