二、词法分析,《编译原理》(本科教学版),第2版

文章目录

    • 一、词法分析器
      • [1.1 词法分析器的作用](#1.1 词法分析器的作用)
      • [1.2 词法分析器的设计方法](#1.2 词法分析器的设计方法)
      • [1.3Antlr 词法分析器生成器](#1.3Antlr 词法分析器生成器)
        • [1.3.1 环境准备](#1.3.1 环境准备)
        • [1.3.2 词法分析器自动生成初体验(需了解少许正则表达式概念)](#1.3.2 词法分析器自动生成初体验(需了解少许正则表达式概念))
          • [1.3.2.1 创建工程](#1.3.2.1 创建工程)
          • [1.3.2.2 空白符逻辑](#1.3.2.2 空白符逻辑)
          • [1.3.2.3 实现INT类型](#1.3.2.3 实现INT类型)
          • [1.3.2.4 实现单行注释](#1.3.2.4 实现单行注释)
          • [1.3.2.5 实现多行注释](#1.3.2.5 实现多行注释)
          • [1.3.2.6 最前优先匹配原则](#1.3.2.6 最前优先匹配原则)
          • [1.3.2.7 字面量词法单元](#1.3.2.7 字面量词法单元)
          • [1.3.2.8 fragment](#1.3.2.8 fragment)
          • [1.3.2.9 实现 float 类型](#1.3.2.9 实现 float 类型)
          • [1.3.2.10 补集](#1.3.2.10 补集)
          • [1.3.2.11 编码规范](#1.3.2.11 编码规范)
          • [1.3.2.12 antlr-v4 中的 冲突解决 规则](#1.3.2.12 antlr-v4 中的 冲突解决 规则)
      • [1.4 手写词法分析器](#1.4 手写词法分析器)
        • [1.4.1 .g4 中词法单元定义](#1.4.1 .g4 中词法单元定义)
        • [1.4.2 TokenType 类](#1.4.2 TokenType 类)
        • [1.4.3 Token 类](#1.4.3 Token 类)
        • [1.4.4 KeywordTable 类](#1.4.4 KeywordTable 类)
        • [1.4.5 Lexer 抽象类](#1.4.5 Lexer 抽象类)
        • [1.4.6 DragonLexer 类](#1.4.6 DragonLexer 类)
    • 二、形式语言
      • [2.1 基本概念](#2.1 基本概念)
      • [2.2 正则表达式](#2.2 正则表达式)
        • [2.2.1 基本概念](#2.2.1 基本概念)
        • [2.2.2 正则表达式描述单词](#2.2.2 正则表达式描述单词)
        • [2.2.3 词法分析中的单词描述](#2.2.3 词法分析中的单词描述)
        • [2.2.4 Lex 中的正则表达式扩展表示方法](#2.2.4 Lex 中的正则表达式扩展表示方法)
        • [2.2.5 正则表达式简记法](#2.2.5 正则表达式简记法)
        • [2.2.6 regex101](#2.2.6 regex101)
        • [2.2.7 正则表达式 regex101 练习(补档)](#2.2.7 正则表达式 regex101 练习(补档))
    • [2.3 自动机理论](#2.3 自动机理论)
      • [2.3.1 不确定的有穷自动机](#2.3.1 不确定的有穷自动机)
      • [2.3.2 确定性有穷自动机](#2.3.2 确定性有穷自动机)
      • [2.3.3 NFA 和 DFA 的比较](#2.3.3 NFA 和 DFA 的比较)
      • [2.3.4 从 RE 到 NFA:Thompson 构造法](#2.3.4 从 RE 到 NFA:Thompson 构造法)
      • [2.3.5 从 NFA 到 DFA 子集构造法](#2.3.5 从 NFA 到 DFA 子集构造法)
      • [2.3.6 最小化DFA状态数](#2.3.6 最小化DFA状态数)
        • [2.3.6.1 闭包](#2.3.6.1 闭包)
        • [2.3.6.2 DFA 最小化算法](#2.3.6.2 DFA 最小化算法)
      • [2.3.7 从DFA 到 词法分析器](#2.3.7 从DFA 到 词法分析器)

一、词法分析器

1.1 词法分析器的作用

词法分析是编译的第一阶段。词法分析器的主要任务是:

  • 对输入的字符串形式的源程序按顺序进行扫描,识别输出具有独立意义的单词序列。
  • 检查源程序中的词法错误。

比如下面一段C程序:

c++ 复制代码
int main(){
    int x1 = 1;
    if (x1 > 0)
        x1 += 10;
    return 0;
}
  • 词法分析程序输出由单词内容和单词的类别组成的内部表示序列。

单词:是指具有独立含义的最小的语义单位。

单词主要分为:

  • 保留字,while,do

  • 标识符,x1,class

  • 常量,10,3.14

  • 特殊符号

    • 运算符,+,%
    • 界限符,{,},;
    • 格式符,EOF

其中,保留字和特殊符号是有限的 ,标识符和常量可以是无限的

我们对上面的C程序进行词法分析能够得到:

  • <保留字, int> <保留字, main> <界限符,(> <界限符,)>
  • <界限符,{> <保留字,int> <标识符,x1><运算符,=><常量,1><界限符;>
  • <保留字,if><界限符,(><标识符,x1><运算符,>><常量,0><界限符,)>
  • <标识符,x1><运算符+=><常量,10><界限符;>
  • <保留字, return > <常量, 0><界限符;>
  • <界限符,}>

那么如何实现词法分析器?

  • 明确要分析的问题
    • 解决信息,输入,处理,输出,数据结构,算法
  • 利用形式化方法描述各类单词的词法规则
    • 正则表达式
    • 自动机
  • 设计词法分析算法

1.2 词法分析器的设计方法

但是很多生产环境下的编译器(如 gcc)仍选择手写词法分析器

事实上,手写词法分析器我们可以加入更多的词法错误分析,而且代码量也不大。

1.3Antlr 词法分析器生成器

Antlr 是一个自动化的工具。

输入:词法单元的规约

  • SimpleExpr.g4

输出:词法分析器

  • SimpleExprLexer.java
1.3.1 环境准备

只需在 IDEA中安装 ANTLR v4 插件

1.3.2 词法分析器自动生成初体验(需了解少许正则表达式概念)
1.3.2.1 创建工程

创建工程目录simpleexpr

创建.g4文件,为其命名 SimpleExpr

.g4 文件格式:

复制代码
grammar 文件名;

词法单元: 描述
	   | 描述
	   ...
	   ;

如:

复制代码
grammar SimpleExpr;

prog : stat* EOF ;

stat : expr ';'
     | ID '=' expr ';'
     | 'print' expr ';'
     ;

expr : expr ('*' | '/') expr
     | expr ('+' | '-') expr
     | '(' expr ')'
     | ID
     ;

ID : ('_' | [a-zA-Z]) ('_' | [a-zA-Z0-9])* ;
  • 描述prog expr星闭包后 遇到 EOF结束
  • 描述stat:expr ';' 或 ID '=' expr 或 print expr
  • 描述expr: expr 运算符 expr 或者 单独一个ID也算expr 然后加上一些括号逻辑
  • 对于一个词法单元的多个逻辑谁在前面谁优先级高,这是我们上面运算符优先级的实现逻辑

我们注意到描述是可以递归嵌套的

简单运行一下:

我们输入两个表达式 a + b c + d 右边生成了语法树

1.3.2.2 空白符逻辑

我们注意到报错了:无法识别空白符

我们尝试在代码最后一行加上对于空白符 制表符换行符回车符的描述:

复制代码
WS : [ \t\r\n]+ ;

然后语法树就把 它们 也加进来了......

事实上我们遇到空白符应该跳过:

复制代码
WS : [ \t\r\n]+ -> skip;

然后就没问题了

我们尝试验证一下运算符优先级的表达式

也是没有问题的:

1.3.2.3 实现INT类型

上面的ID是标识符,那么我们下面尝试引入数据类型,先来个简单的 INT

复制代码
INT : '0' | [1-9][0-9]* ;

运行也是没有问题

1.3.2.4 实现单行注释

尝试实现一下单行注释逻辑: '//' 开头 中间内容任意 '\n' 结束

复制代码
SL_COMMENT : '//' .* '\n' -> skip ;

运行发现出问题了: 因为要找结束'\n' 发现直接越过本行注释到了最后一行, 导致单行注释及以下全被吞掉了

这是贪婪匹配导致的, 解决方式就是改成非贪婪匹配

复制代码
SL_COMMENT : '//' .*? '\n' -> skip ;

运行正常了

我们只在.* 后面加了 ?, 这代表非贪婪匹配, 贪婪匹配是能匹配多长就匹配多长

1.3.2.5 实现多行注释
复制代码
ML_COMMENT : '/*' .*? '*/' -> skip;

也是十分的容易

1.3.2.6 最前优先匹配原则

我们尝试写一下文档注释 DOCS_COMMENT

先来个错误的写法说明一下antlr-4 的优先匹配原则

复制代码
SL_COMMENT : '//' .*? '\n' -> skip ;
ML_COMMENT : '/*' .*? '*/' ;
DOCS_COMMENT : '/**' .*? '*/' ;

一定会出错, 但是我们发现文档注释 被识别成了 多行注释!

这是因为我们把 多行注释词法单元的规约写在了 文档注释前面

更改顺序后就识别为了文档注释

正确代码:

复制代码
SL_COMMENT : '//' .*? '\n' -> skip ;
DOCS_COMMENT : '/**' .*? '*/' -> skip ;
ML_COMMENT : '/*' .*? '*/' -> skip ;
1.3.2.7 字面量词法单元

我们再增加一句print a 看看会发现什么?

print 并没有被识别为ID, 然后想到为什么 ';' 没有被识别为ID

这是因为print 这个字面量我们没有给其开一个词法单元, 但是antlr-4 会自动给我们创建一个词法单元:

类似

复制代码
PRINT : 'print' ;
SEMI : ';' ;

并且自动生成的词法单元会放在自定义词法单元前面

事实上 antlr-4 为我们提供了生成字面量词法单元的功能:

1.3.2.8 fragment

我们重复用正则表达式写了字母集合 a-zA-Z 和 数字0-9

antlr-4 为我们提供了fragment, 可以将重复的东西提取出来:

复制代码
fragment LETTER : [a-zA-Z] ;
fragment NUMBER : [0-9] ;
fragment WORD : '_' | LETTER | NUMBER ;

我们之前的代码可以优化:

复制代码
ID : ('_' | LETTER) WORD* ;
INT : '0' | [1-9]NUMBER* ;
1.3.2.9 实现 float 类型
复制代码
FLOAT : INT '.' NUMBER+ ;

我们也可以在INT中增加 正负号逻辑

复制代码
INT : '0' | ('+' | '-')? [1-9]NUMBER* ;
1.3.2.10 补集

我们对于单行注释的非贪婪匹配处理也可以换成补集(~ 表示)操作:

复制代码
SL_COMMENT : '//' (~'\n')*? '\n' -> skip ;
1.3.2.11 编码规范

一般来说语法单元的规约都是小写字母表示, 比如我们前面的 expr. stat等

词法单元的规约一般都是大写字母表示, 比如前面的 ID, LETTER

1.3.2.12 antlr-v4 中的 冲突解决 规则

最前优先匹配: 关键字 vs 标识符

ML_COMMENT vs. DOC_COMMENT

最长优先匹配: 1.23, >= ,ifhappy

  • 1.23 不会被识别为 INT . INT, 而是识别为float
  • >= 不会被识别为 GT E 而是被识别为 GE
  • ifhappy 不会被识别为 if ID 而是识别为ifhappy

非贪婪匹配: ()??. ()*?, ()+?

1.4 手写词法分析器

现在通过编程的方式来手写一个词法分析器。

尝试实现下面词法单元

需要说明的是上图中的 '=' 是逻辑运算符,判断是否相等,<> 代表不等号

上图中对number的逻辑进行了简化,即允许前导0

在前面用antlr-v4 设计词法分析器的时候,我们前面写的是语法规则后面写的词法规则。

事实上,在工程中我们一般将语法规则和词法规则分别写在两个工程文件中。

因为不同的语言语法差异会有,但是很多时候词法的共通的。

在antlr-v4中,如果要声明本文件只描述词法单元可以这么写文件头:

复制代码
lexer grammar 文件名;
1.4.1 .g4 中词法单元定义
复制代码
lexer grammar DragonLexerRules;

@header {
package dragon;
}

DOT : '.' ;
POS : '+' ;
NEG : '-' ;

EQ : '=' ;
NE : '<>' ;
LT : '<' ;
LE : '<=' ;
GT : '>' ;
GE : '>=' ;

IF : 'if' ;
ELSE : 'else' ;
ID : LETTER (LETTER | DIGIT)* ;

// pay more attention to this group
INT : DIGITS ;
// here "2." is an invalid REAL
REAL : DIGITS ('.' DIGITS)? ;
// both "2.99792458E8" and "3E8" are valid SCI
SCI : DIGITS ('.' DIGITS)? ([eE] [+-]? DIGITS)? ;

WS : [ \t\r\n]+ -> skip;

fragment DIGIT :  [0-9] ;
fragment DIGITS : [0-9]+ ;
fragment LETTER : [a-zA-Z] ;
1.4.2 TokenType 类

定义枚举类型 TokenType,包含了我们要实现的词法单元

大概分为三类,详见代码注释

java 复制代码
package dragon;

/**
 * Types of tokens
 * Grouped by hardness of recognition
 */
public enum TokenType {
    // Group 0
    EOF,  // end of file
    UNKNOWN,  // for error

    // Group 1
    // lookhead = 1 (LA(1))
    DOT, POS, NEG,
    IF, ELSE,
    ID,
    INT,
    WS,

    // Group 2
    // =, <>, <, <=, >, >=
    // LA(2)
    EQ, NE, LT, LE, GT, GE,

    // Group 3
    // arbitrary LA
    REAL,
    SCI,
}
1.4.3 Token 类

词法单元类 Token

我们为保留字以及一些运算符直接创建静态公共变量。

该类包含其类型 type 以及 文本 text

然后提供一些接口:

  • getText
    • 获取文本
  • toString
    • 返回一个字符串用于后续格式化输出
java 复制代码
package main.java.dragon;

public class Token {
    public static final Token EOF = new Token(TokenType.EOF, "EOF");
    public static final Token WS = new Token(TokenType.WS, " ");

    public static final Token IF = new Token(TokenType.IF, "if");
    public static final Token ELSE = new Token(TokenType.ELSE, "else");

    public static final Token EQ = new Token(TokenType.EQ, "=");
    public static final Token NE = new Token(TokenType.NE, "<>");
    public static final Token LT = new Token(TokenType.LT, "<");
    public static final Token LE = new Token(TokenType.LE, "<=");
    public static final Token GT = new Token(TokenType.GT, ">");
    public static final Token GE = new Token(TokenType.GE, ">=");
    public static final Token DOT = new Token(TokenType.DOT, ".");
    public static final Token POS = new Token(TokenType.POS, "+");
    public static final Token NEG = new Token(TokenType.NEG, "-");

    private final TokenType type;
    private final String text;

    public Token(TokenType type, String text) {
        this.type = type;
        this.text = text;
    }

    public String getText() {
        return this.text;
    }

    @Override
    public String toString() {
        return String.format("token {type : %s, text : %s}",
                type, text);
    }
}
1.4.4 KeywordTable 类

KeywordTable 类主要通过哈希表记录保留字以及其Token

代码非常简单

java 复制代码
package main.java.dragon;

import java.util.HashMap;
import java.util.Map;

public class KeywordTable {
    private final Map<String, Token> keywords = new HashMap<>();

    public KeywordTable() {
        this.reserve(Token.IF);
        this.reserve(Token.ELSE);
    }

    public Token getKeyword(String text) {
        return keywords.get(text);
    }

    private void reserve(Token token) {
        keywords.put(token.getText(), token);
    }
}
1.4.5 Lexer 抽象类

Lexer 是词法分析器抽象类,主要实现一些基本接口,提供一些抽象接口

成员变量:

  • input,待分析输入字符流
  • peek,当前匹配到的字符
  • pos,当前匹配到的位置

方法:

  • nextToken:抽象方法,由子类实现
  • advance:pos 后移,如果pos 到达input末尾,我们设置peek为非法字符,否则设置为对应下标字符,然后返回peek
  • reset:重新设置pos和peek
java 复制代码
package main.java.dragon;

public abstract class Lexer {
    final String input;
    char peek;
    int pos;

    public Lexer(String input) {
        this.input = input;
        this.pos = 0;
        this.peek = input.charAt(pos);
    }

    public abstract Token nextToken();

    public void advance() {
        ++ pos;
        if (pos >= input.length()) {
            peek = Character.MIN_VALUE;
        }
        else {
            peek = input.charAt(pos);
        }
    }

    public void reset(int pos) {
        this.pos = pos;
        this.peek = input.charAt(pos);
    }
}
1.4.6 DragonLexer 类

因为参考龙书,所以命名为DragonLexer

基本框架

java 复制代码
package main.java.dragon;

public class DragonLexer extends Lexer {
    private int lastMatchPos = 0;	// 上一次匹配成功位置
    private int longestValidPrefixPos = 0;	// 当前最长有效匹配前缀下标,用于回退
    private TokenType longestValidPrefixType = null;	// 当前最长有效匹配前缀类型,用于回退

    private final KeywordTable kwTable = new KeywordTable();

    public DragonLexer(String input) {
        super(input);
    }

    @Override
    public Token nextToken() {};	// 返回字符流中下一个 Token

    private Token NUMBER() {};	// 返回一个NUMBER Token

    private Token backToLongestMatch() {};	// 陷入结束状态或非法状态时回溯得到上个最长匹配Token

    private Token WS() {};	// 返回空白符 Token

    private Token ID() {};	// 返回标识符 Token

    private Token INT() {};	// 返回 INT Token

    private Token RELOP() {};	// 返回运算符 Token
}

状态逻辑如下:

  • 对于13、15、18 无论遇到什么字符,其下一个状态很明确
  • 19、20、21 代表了不同的数字类型
  • 14、16、17 如果遇到other,我们选择回退,返回最长匹配

具体实现如下:

其实就是对于状态图的大模拟,比较繁琐,但是没有思维困难

java 复制代码
package main.java.dragon;

public class DragonLexer extends Lexer {
    private int lastMatchPos = 0;
    private int longestValidPrefixPos = 0;
    private TokenType longestValidPrefixType = null;

    private final KeywordTable kwTable = new KeywordTable();

    public DragonLexer(String input) {
        super(input);
    }

    @Override
    public Token nextToken() {
        if (pos == input.length()) {
            return Token.EOF;
        }

        Token token = null;
        if (Character.isWhitespace(peek)) {
            token = WS();
        } else if (Character.isLetter(peek)) {
            token = ID();
        } else if (Character.isDigit(peek)) {
            token = NUMBER();
        } else if (peek == '=') {
            token = Token.EQ;
            advance();
        } else if (peek == '>') {
            advance();
            if (peek == '=') {
                token = Token.GE;
                advance();
            } else {
                token = Token.GT;
            }
        } else if (peek == '<') {
            advance();
            if (peek == '=') {
                token = Token.LE;
                advance();
            } else if (peek == '>') {
                token = Token.NE;
                advance();
            } else {
                token = Token.LT;
            }
        } else if (peek == '.') {
            token = Token.DOT;
            advance();
        } else if (peek == '+') {
            token = Token.POS;
            advance();
        } else if (peek == '-') {
            token = Token.NEG;
            advance();
        } else {
            token = new Token(TokenType.UNKNOWN, Character.toString(peek));
            advance();
        }

        this.lastMatchPos = pos;
        return token;
    }

    private Token NUMBER() {
        int state = 13;
        advance();

        while (true) {
            switch (state) {
                case 13:
                    longestValidPrefixPos = pos;
                    longestValidPrefixType = TokenType.INT;

                    if (Character.isDigit(peek)) {
                        advance();
                    } else if (peek == '.') {
                        state = 14;
                        advance();
                    } else if (peek == 'E' || peek == 'e') {
                        state = 16;
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                case 14:
                    if (Character.isDigit(peek)) {
                        state = 15;
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                case 15:
                    longestValidPrefixPos = pos;
                    longestValidPrefixType = TokenType.REAL;

                    if (Character.isDigit(peek)) {
                        advance();
                    } else if (peek == 'E' || peek == 'e') {
                        state = 16;
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                case 16:
                    if (peek == '+' || peek == '-') {
                        state = 17;
                        advance();
                    } else if (Character.isDigit(peek)) {
                        state = 18;
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                case 17:
                    if (Character.isDigit(peek)) {
                        state = 18;
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                case 18:
                    longestValidPrefixPos = pos;
                    longestValidPrefixType = TokenType.SCI;

                    if (Character.isDigit(peek)) {
                        advance();
                    } else {
                        return backToLongestMatch();
                    }
                    break;
                default:
                    System.err.println("Unreachable");
            }
        }
    }

    private Token backToLongestMatch() {
        Token token = new Token(longestValidPrefixType,
                input.substring(lastMatchPos, longestValidPrefixPos));

        System.out.println(lastMatchPos + ":" + (longestValidPrefixPos - 1));

        if (longestValidPrefixPos < input.length()) {
            this.reset(longestValidPrefixPos);
        }

        return token;
    }

    private Token WS() {
        while (Character.isWhitespace(peek)) {
            advance();
        }

        return Token.WS;
    }

    private Token ID() {
        StringBuilder sb = new StringBuilder();

        do {
            sb.append(peek);
            advance();
        } while (Character.isLetterOrDigit(peek));

        Token token = kwTable.getKeyword(sb.toString());
        if (token == null) {
            return new Token(TokenType.ID, sb.toString());
        }
        return token;
    }

    private Token INT() {

        return null;
    }

    private Token RELOP() {

        return RELOP();
    }
}

测试代码

java 复制代码
package main.java.dragon;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class DragonLexerTest {
    public static void main(String[] args) throws IOException {
        String input = Files.readString(Path.of("src/main/antlr/dragon/dragon0.txt"));
        DragonLexer lexer = new DragonLexer(input);

        Token token = lexer.nextToken();

        while (token != Token.EOF) {
            if (token != Token.WS) {
                System.out.println(token);
            }
            token = lexer.nextToken();
        }
    }
}

测试输入

tex 复制代码
if happy hello else world

<<
<=
<==
>>>>===

123xyz
123.xyz
123Ex
123E+

123.45xyz
123.45E+
123.45Exyz

123.45E+xyz
123.45E+67
123.45E67xyz

123E67xyz
123E+xyz
123E+67xyz

123.45E-67
123.45-67

123>122.57

运行结果

java 复制代码
token {type : IF, text : if}
token {type : ID, text : happy}
token {type : ID, text : hello}
token {type : ELSE, text : else}
token {type : ID, text : world}
token {type : LT, text : <}
token {type : LT, text : <}
token {type : LE, text : <=}
token {type : LE, text : <=}
token {type : EQ, text : =}
token {type : GT, text : >}
token {type : GT, text : >}
token {type : GT, text : >}
token {type : GE, text : >=}
token {type : EQ, text : =}
token {type : EQ, text : =}
53:55
token {type : INT, text : 123}
token {type : ID, text : xyz}
61:63
token {type : INT, text : 123}
token {type : DOT, text : .}
token {type : ID, text : xyz}
70:72
token {type : INT, text : 123}
token {type : ID, text : Ex}
77:79
token {type : INT, text : 123}
token {type : ID, text : E}
token {type : POS, text : +}
86:91
token {type : REAL, text : 123.45}
token {type : ID, text : xyz}
97:102
token {type : REAL, text : 123.45}
token {type : ID, text : E}
token {type : POS, text : +}
107:112
token {type : REAL, text : 123.45}
token {type : ID, text : Exyz}
121:126
token {type : REAL, text : 123.45}
token {type : ID, text : E}
token {type : POS, text : +}
token {type : ID, text : xyz}
134:143
token {type : SCI, text : 123.45E+67}
146:154
token {type : SCI, text : 123.45E67}
token {type : ID, text : xyz}
162:167
token {type : SCI, text : 123E67}
token {type : ID, text : xyz}
173:175
token {type : INT, text : 123}
token {type : ID, text : E}
token {type : POS, text : +}
token {type : ID, text : xyz}
183:189
token {type : SCI, text : 123E+67}
token {type : ID, text : xyz}
197:206
token {type : SCI, text : 123.45E-67}
209:214
token {type : REAL, text : 123.45}
token {type : NEG, text : -}
216:217
token {type : INT, text : 67}
222:224
token {type : INT, text : 123}
token {type : GT, text : >}
226:231
token {type : REAL, text : 122.57}

二、形式语言

2.1 基本概念

字母表(Alphabet)

字母表是元素的非空有穷集合,通常用 Σ 表示。

字母表中的一个元素称为该字母表的一个字母(letter),也可叫做符号(Symbol),或字符(Character)。

例如:Σ = { a, b, ..., z, A, B, ..., Z }, Σ = { 0, 1, ..., 9 }, Σ = ASCII, Σ = Unicode

符号串

  • 符号串是由字母表中的符号组成的任意有穷序列。符号串还可以称为**"字符串"句子,一般用α,β,...,x,y,z**表示。
  • 符号表中字符的个数称为符号串长度 ,用**|β|表示符号串β**的长度。
  • \\varepsilon 表示空串,|\\varepsilon | = 0 -> 没有任何字符
  • 例如:Σ = { 0,1 },α = 01,β = 101

符号串的连接

αB 均是字母表 上的符号串,αβ 的连接是把 β 的所有符号顺次地接在 α 的所有符号之后所得到的符号串。记为: αβ.

例如: 设 α= abc, β= de, 则α和β的连接:

aβ = abcde

|αβ| = α| + |β|。

由于ε是不包含任何符号的字符串,特别有:εα = αε = x

字符串的方幂

设x是字母表∑上的符号串,把x自身连接n次得到的符号串z,称作符号串x的n次幂,记作z=x

根据定义有:

  • x 0 = ε x^0 = \varepsilon x0=ε
  • x 1 = x x^1 = x x1=x
  • x 2 = x x x^2 = xx x2=xx
  • ...
  • x n = x n − 1 x = x x n − 1 = x x . . . x x ( n 个 x ) x^n = x^{n-1}x = xx^{n-1} = xx...xx(n个x) xn=xn−1x=xxn−1=xx...xx(n个x)

字符串的集合

若集合A中的所有元素都是某字母表 ∑ 上的符号串则称A为该字母表上的符号串集合

2.2 正则表达式

2.2.1 基本概念

正则表达式是用来描述正则集的一种代数表达式,也称为正规表达式。

  • 形式:用事先定义好的一些特定字符,以及对这些特定字符进行组合运算,形成的一个"规则字符串"。
    • 如:( 1 | 2 | ... | 9 ) ( 0 | 1 | 2 | ... | 9 ) * | 0
  • 作用:用来定义一类字符串中的一种过滤逻辑

所有符合正则表达式 r 所定义的规则(模式)的符号串集合,称为正则集或正规集,表示为L®。L®也称为由 r 定义的语言我们称为正则集或者正规集。

1、正则表达式的递归定义

Σ 为字母表

  1. \\varepsilon 和 \\emptyset 是 Σ 上的正则表达式,它们所表示的正则集分别为 L( ε \varepsilon ε) = ( ε \varepsilon ε),L( \\emptyset ) = {}
  2. 对任何 a ∈ Σ a \in \Sigma a∈Σ, a 是 Σ上的正则表达式,它所表示的正则集 L(a) = {a}
  3. 若 r 和 s 都是 Σ 上的正则表达式,它们所表示的正则集分别为L® 和 L(s)
    1. ® 也是 Σ上的正则表达式,表示的正则集L(®) = L®
    2. r|s 也是 Σ 上的正则表达式,表示的正则集是L(r|s) = L® \\cup L(s)
    3. r · s 也是 Σ 上的正则表达式,表示的正则集是L(r · s) = L®L(s)
    4. r* 也是 Σ 上的正则表达式,表示的正则集L(r*) = (L®)*

其中:

  • 括号 () 不改变 r 所代表正则集,只是用来确定运算优先关系
  • 或运算 |:把复杂问题分成几个种情况依次定义正则表达式,然后把这些正则表达式用或运算连接起来描述整个问题
  • 连接运算 · : 把一个大问题分成前后关联的几个部分依次定义正则表达式,然后把各部分正则表达式按先后顺序用连接运算
    连接起来描述问题。为了方便表达,经常省略这个
    • 例如: r · s 也可表示为 rs 。
  • *运算: r* 表示对正则表达式 r 所描述的文本进行0 到若干次循环。我们称之为 星闭包
  • 补充:+运算: r + r^+ r+ 表示对正则表达式 r 所描述的文本进行1 到若干次循环。我们称之为 正闭包
2.2.2 正则表达式描述单词

1、正则表达式的性质

  1. 交换律:A | B = B | A

  2. 结合律:A | B | C = (A | B) | C, A | (B | C)

    ​ ABC = (AB)C = A(BC)

  3. 分配律:A(B|C) = AB|AC (A|B)C = AC|BC

  4. 幂等律:A** = A*

  5. 同一律:A ε \varepsilon ε = A

例1:

Σ = { a, b }

1、ab*,表示a开头后面跟0或任意多个b的字符串集合

2、a(a|b)*,Σ上所有以a开头的字符串集合

例2:

D = 0|1|2...|9,D表示一位数字

则 D+ 表示允许前导0整数

D = 0|1|2...|9,D1 = 1|2...|9

D1D*:表示无符号正整数

(+D1D*) | (-D1D*) | 0: 表示有符号整数

(+|-| ε \varepsilon ε)(D1D*)|0 表示整数

2.2.3 词法分析中的单词描述
  • 保留字:while|if|for|......
  • 标识符:L(L|D)*
    • 其中 L = A|B|...Z|a|b|...|z|_
    • D = 0|1|...|9
  • 常数
    • 整数:(+|-| ε \varepsilon ε)(D1D*)|0,其中D1 = 1|2|...|9
    • 实数:(+|-| ε \varepsilon ε)(D1D*|0).D+
  • 特殊符号
    • 运算符:+ | - | * | ...
    • 分界符:{ | } | ; | ...
    • 制表符:\t | \n | ...
2.2.4 Lex 中的正则表达式扩展表示方法

龙书 中以 Lex 工具为例, 事实上不同的工具对于 正则表达式的扩展方法表示不同, 比如在 antlr-v4 中 . 代表 任意字符, 而 Lex 中代表除了换行符外的任意字符.

2.2.5 正则表达式简记法

这里给了 Vim 和 Java 的简记法, 可惜 antlr-v4不支持, 不过 vim 工具还是比较常用的, 所以贴过来了(不过喔没学过java)

2.2.6 regex101

https://regex101.com/ 是一个练习正则表达式的非常不错的网站

比如我们要用正则表达式实现 二进制下 3的倍数 的匹配:

至于为什么能这么表示, 如何推出, 学习完自动机理论应该就会了?

2.2.7 正则表达式 regex101 练习(补档)

https://regex101.com/r/jucEtW/1

花括号里面写6代表恰好匹配6个

复制代码
#[a-fA-F0-9]{6}

https://regex101.com/r/jchuZs/1

复制代码
\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4}

https://regex101.com/r/fWJkCF/1匹配大于等于100的金额

复制代码
\$\d{3,}\.\d{1,2}

https://regex101.com/r/K5MCMZ/1匹配文本中的cat**单词**

\b 代表非单词的边界

复制代码
\bcat\b

https://regex101.com/r/C0m3kB/1匹配三的倍数

复制代码
^(0|(1(01*0)*1))*$

原理不解释, DFA转RE的Kleene算法我不会qaq

需要注意的是:

^ : 定位符:匹配输入字符串的开始位置, 写在\[\]之外, 写在\[\]里面就是取反了

$ : 定位符:匹配输入字符串的结束位置, 写在\[\]之外

https://regex101.com/r/PUsCwP/1

同样是匹配html标签, 但是标签开头和结束必须匹配

复制代码
<[hH]([1-6])>.*?<\/[hH]\1>

我们把第一个<> 内的数字用圆括号括起来, 这样变成了一个子表达式, 在后面引用即可: \1

https://regex101.com/r/eXue43/1

匹配合法html标签, 但是只返回第一个<>内的数字和中间部分

复制代码
(?<=<[hH]([1-6])>).*?(?=<\/[hH]\1>)

向前看原则 ?=: 前面如果匹配成功, 前面的部分我们匹配但是不返回

向后看原则 ?<=: 后面如果匹配成功, 后面的部分我们匹配但是不返回

https://regex101.com/r/l07Gpu/1

复制代码
(<a[^>]+>)?<img[^>]+>(?(1)<\/a>)

这里实现的逻辑其实是把第一个<> 作为一个子表达式, 后面那个<> 是否匹配取决于前面是否匹配到

所以后面写了个?(1), 功能就是询问子表达式1是否匹配, 匹配成功我们才选择匹配

2.3 自动机理论

下面我们揭示 antlr-v4 是如何将它的输入程序变成一个词法分析器的,。

转换的核心被称为 有穷自动机(finite automata)

有穷自动机分为两类:

  • 不确定的有穷自动机(Nondeterministic Finite Automata,NFA)对其边上的标号没有任何限制。一个标号标记离开同一状态的多条边 ,并且空串 ϵ \epsilon ϵ 也可以作为标号。
  • 对于每个状态及自动机输入字母表中的每个符号,确定的有穷自动机(Deterministic Finite Automata)有且只有一条离开该状态、以该符号为标号的边。

确定的和不确定的有穷自动机能识别的语言的集合都是能够用正则表达式描述的语言的集合,这个集合中的语言被称为正则语言(regular language)

2.3.1 不确定的有穷自动机

非确定性有穷自动机(NFA, Nondeterministic Finite Automation)A是一个五元组 A = ( Σ , S , s 0 , δ , F ) A = (\Sigma, S, s_0, \delta, F) A=(Σ,S,s0,δ,F):

  • 一个输入符号表,即字母表 Σ ( ϵ ∉ Σ ) Σ(\epsilon \notin \Sigma) Σ(ϵ∈/Σ)
  • 一个有穷的状态集合S
  • 唯一的初始状态 s 0 ∈ S s_0 \in S s0∈S
  • 状态转移函数 δ \delta δ
    • δ : S × ( Σ ∪ { ϵ } ) → 2 S \delta : S \times (\Sigma \cup \{\epsilon \}) \rightarrow 2^S δ:S×(Σ∪{ϵ})→2S
  • 接受状态集合 F ⊆ S F \subseteq S F⊆S

**约定:**所有没有对应出边的字符默认指向一个"死状态"

下面所有例图中:同心圆为接受状态

(非确定性)有穷自动机是一类极其简单的计算装置

它可以识别(接受/拒绝)上的字符串

接受(Accept) :(非确定性)有穷自动机 A 接受字符串 x,当且仅当存在一条从开始状态 s 0 s_0 s0到某个接受状态 f ∈ F f \in F f∈F、标号为 x 的路径。

因此,A 定义了一种语言L(A): 它能接收的所有字符串构成的集合。

上面的NFA描述的是:L(A) = L((a | b)* abb)

显然有:

a a b b ∈ L ( A ) a b a b a b ∉ L ( A ) aabb \in L(A) \ \ \ ababab \notin L(A) aabb∈L(A) ababab∈/L(A)

再看一个例子:

根据终止状态为S1 和 S3 可推断出:

L(A) = { 包含偶数个0 或者 偶数个1 的01串 }

2.3.2 确定性有穷自动机

**确定性有穷自动机(Determinitive Finite Automation)**A是一个五元组 A = { Σ , S , s 0 , δ , F } A = \{\Sigma, S, s_0, \delta, F\} A={Σ,S,s0,δ,F}

  • 字母表 Σ ( ϵ ∉ Σ ) \Sigma(\epsilon \notin \Sigma) Σ(ϵ∈/Σ)
  • 有穷的状态集合S
  • 唯一 的初始状态 s 0 ∈ S s_0 \in S s0∈S
  • 状态转移函数 δ \delta δ
  • 接受状态集合 F ⊆ S F \subseteq S F⊆S

上图中有 L(A) = L((a | b)* abb)

2.3.3 NFA 和 DFA 的比较

NFA 简洁易于理解,便于描述语言 L ( A ) L(A) L(A)

DFA 易于判断 s ∈ L ( A ) s \in L(A) s∈L(A),适合产生词法分析器

根据二者特性,我们通常:

用 NFA 描述语言,用 DFA 实现词法分析器

  • RE => NFA => DFA => 词法分析器

2.3.4 从 RE 到 NFA:Thompson 构造法

Thompson 构造法基本思想:按结构归纳

  1. ϵ \epsilon ϵ 是 正则表达式

  2. a \\in \\Sigma 是正则表达式

  3. 如果 s 是正则表达式,则 (s) 是 正则表达式

    • N((s)) = N(s)
  4. 如果 s,t 是正则表达式,则 s | t 是正则表达式

    • 问题:如果 N(s) 或 N(t) 的开始状态或接受状态不唯一,怎么办?

      根据归纳假设 ,N(s) 和 N(t) 的开始状态与接受状态均唯一

      即,我们每次都是将正则表达式的一部分拿出来构造一个唯一开始状态和接受状态的NFA,两个 NFA 再构造一个唯一开始状态和接受状态的NFA......

  5. 如果 s,t 是正则表达式,则 st 是正则表达式

  6. 如果 s 是正则表达式,则 s* 是正则表达式

N® 的 性质 以及 Thompson 构造法的复杂度分析

  1. N® 的 开始状态 与 接受状态 均唯一

  2. 开始状态没有入边,接受状态没有出边

  3. N® 的 状态数 |S| <= 2 * |r|

    |r| 是 r 中运算符 和 运算分量的总和

    **证明:**我们上面构造步骤中,每一步至多增加1个初始状态,1个接受状态,故最多就是 2 * |r| 个状态

练习:

r = (a | b)* abb 对应的NFA?

2.3.5 从 NFA 到 DFA 子集构造法

思想:用 DFA 模拟 NFA

子集构造法的基本思想是让构造得到的DFA的每个状态对应于NFA的一个状态集合

DFA 在读入输入 a 1 a 2 . . . a n a_1a_2...a_n a1a2...an之后到达的状态对应于相应的NFA从开始状态出发,沿着以a_1a_2...a_n 为标号的路径能够到达的状态的集合。

**子集构造(subset construction)**算法:

龙书上给出了NFA上的三种操作,这是我们DFA中状态的产生方式:

  • 初始, ϵ \epsilon ϵ-closure(s0) 是 状态集合 Dstates 中 唯一状态,且它未加标记

复制代码
  while (在 Dstates 中有一个未标记状态T) {
  	给 T 加上标记
      for (每个输入符号a) {
  		U = epsilon-closure(move(T, a));
  		if (U 不在 Dstates 中)
  			将 U 加入到 Dstates中,且不加标记;
  		Dtran[T, a] = U;
  	}
  }

其实就是暴力(

用更形式化的语言描述:

子集构造法 N => D

N : ( Σ N , S N , n 0 , δ N , F N ) N: (\Sigma_N, S_N, n_0, \delta_N, F_N) N:(ΣN,SN,n0,δN,FN)

D : ( Σ D , S D , d 0 , δ D , F D ) D: (\Sigma_D, S_D, d_0, \delta_D, F_D) D:(ΣD,SD,d0,δD,FD)

符号表不变: Σ N = Σ D \Sigma_N = \Sigma_D ΣN=ΣD

D 的状态集合是N的幂集的子集: S D ⊆ 2 S N , ( ∀ ∈ S D : s D ⊆ S N ) S_D \subseteq 2^{S_N}, (\forall \in S_D: s_D \subseteq S_N) SD⊆2SN,(∀∈SD:sD⊆SN)

初始状态 : d 0 = ϵ − c l o s u r e ( n 0 ) d_0 = \epsilon-closure(n_0) d0=ϵ−closure(n0)

转移函数\\forall a \\in \\Sigma_D: \\delta_D(s_D,a) = \\epsilon-closure(move(s_D,a))

接受状态集 : F D = { s D ∈ S D ∣ ∃ f ∈ F N . f ∈ s D } F_D = \{s_D \in S_D | \exist f \in F_N . f\in s_D \} FD={sD∈SD∣∃f∈FN.f∈sD}

拿个例子模拟一下过程:

这是 (a | b) * abb 对应的 NFA: N

我们求ε-closure 得到 A = { 0, 1, 2, 4, 7 }

对 move(A, a) 求ε-closure 得到 B = { 1, 2, 3, 4, 5, 6, 7, 8 }

对 move(A, B) 求ε-closure 得到 C = { 1, 2, 4, 5, 6, 7 }

对 move(B, a) 求ε-closure 得到 D = { 1, 2, 4, 5, 6, 7, 9 }

对 move(D, b) 求ε-closure 得到 E = { 1, 2, 4, 5, 6, 7, 10 }

对于得到的 DFA: D, 有初始状态 A, 结束状态E

子集构造法的复杂度分析 :
∣ S N ∣ = n ∣ S D ∣ = Θ ( 2 n ) = O ( 2 n ) ∩ Ω ( 2 n ) 对于任何算法 , 最坏情况下 , ∣ S D ∣ = Ω ( 2 n ) ( 如何证明这一点 ? ) \begin{align} & |S_N| = n \\ & |S_D| = \Theta(2^n) = O(2^n) \cap \Omega(2^n) \\ & 对于任何算法, 最坏情况下, |S_D| = \Omega(2^n)(如何证明这一点?) \end{align} ∣SN∣=n∣SD∣=Θ(2n)=O(2n)∩Ω(2n)对于任何算法,最坏情况下,∣SD∣=Ω(2n)(如何证明这一点?)
尝试构造出一个正则集, 使得构造出的 DFA 的状态数 是 2^n

"长度为 m >= n 个 字符的 a, b 串, 且倒数第 n 个字符是 a"

L n = ( a ∣ b ) ∗ a ( a ∣ b ) n − 1 L_n = (a|b)* a(a|b)^{n-1} Ln=(a∣b)∗a(a∣b)n−1

对应NFA:

ps: 上图把 a, b 两条标号边合并一条了, 实际上是两条

得到的DFA:

2.3.6 最小化DFA状态数

下面两个都是 (a | b) * abb 的 DFA, 但是我们发现 子集构造法的DFA 多了一个状态, 状态 A 和 C 具有相同的转换函数, 因此 A, C可以合并

这引出了下一个问题 - 最小化DFA状态数

先回顾下概念:

2.3.6.1 闭包

闭包(closure): f-closure(T)

仍以 ε-closure 为例

我们从一个状态出发, 一直再走ε, 直到走不动, 用形式化语言描述:

T => f(T) => f(f(T)) => f(f(f(T))) => ,

直到找到 x 使得 f(x) = x (x 称为 f 的 不动点)

2.3.6.2 DFA 最小化算法

(上面这个大佬常年在SJTU教书qaq)

如何定义等价状态?

直觉上感觉似乎长下面这样子:
KaTeX parse error: Undefined control sequence: \and at position 102: ...rightarrow}s') \̲a̲n̲d̲ ̲(t \overset{a}{...
但这个定义是错的

仍以下面这个DFA为例:

按照上面的规则有: A ~ C ~ E

接受状态和非接受状态必定不等价, 空串 ε 区分 了这两类状态

再看这个例子:

我们会得出 c ~ d ~ e a 和 b 不等价, 但是 a 和 b 显然是等价的

那么如何定义呢?

将上面的定义改为递归定义:
KaTeX parse error: Undefined control sequence: \and at position 102: ...rightarrow}s') \̲a̲n̲d̲ ̲(t \overset{a}{...

即, 我们s 和 t 在不同字符下可以到达不同的状态, 但是它们到达的状态必须等价

基于该定义, 不断合并等价的状态,直到无法合并为止

但是,这是一个递归定义,从哪里开始呢?

问题: 所有接受状态都是等价的吗?

显然不是, 比如:

我们将定义取反:

我们不选择合并 , 而选择划分

算法描述如下:

输入: 一个 DFA D, 其状态集合为S, 输入字母表为Σ,开始状态为s0, 接受状态集为F

输出: 一个 DFA D', 它和 D 接受相同的语言,且状态数最少

  1. 首先构造包含两个组 F 和 S - F 的初始化分 ∏ \prod ∏, 这两个组分别是 D 的接受状态组和非接受状态组

  2. 最初 , 令 ∏ n e w = ∏ f o r ( ∏ 中的每个组 G ) { 将 G 划分为更小的组 , 使得两个状态 s 和 t 在同一小组中当且仅当对于所有的输入符号 a 状态 s 和 t 在 a 上的转换都到达 ∏ 中的同一组 最坏情况下 , 每个状态各成一组 在 ∏ n e w 中将 G 替换为对 G 划分得到的那些小组 } \begin{align} & 最初, 令 \prod_{new} = \prod\\ & for (\prod 中的每个组 G) \{ \\ &\ \ \ \ \ 将G划分为更小的组, 使得两个状态s 和 t 在同一小组中当且仅当对于所有的输入符号a \\ &\ \ \ \ \ 状态 s 和 t 在 a 上的转换都到达 \prod 中的同一组 \\ &\ \ \ \ \ 最坏情况下, 每个状态各成一组 \\ &\ \ \ \ \ 在\prod_{new} 中将G替换为对G划分得到的那些小组 \\ & \} \end{align} 最初,令new∏=∏for(∏中的每个组G){ 将G划分为更小的组,使得两个状态s和t在同一小组中当且仅当对于所有的输入符号a 状态s和t在a上的转换都到达∏中的同一组 最坏情况下,每个状态各成一组 在new∏中将G替换为对G划分得到的那些小组}

  3. 直到再也无法划分 为止 (不动点!), 然后,将同一等价类里的状态合并

我们仍以下图为例:

第一次按a划分: {{A, B, C, D}, {E}}

第二次划分: {{A, B, C}, {D}, {E}}, 因为D在b上转入了E. E不是{A, B, C, D}的成员

第三次按a划分: {{A, C}, {B}, {D}, {E}}因为A, C 在b上都到达{A, B, C}内的元素, 而 B却到达D

第四次无法划分, 结束

问题: DFA 最小化算法 适用于 NFA 吗?

不适用于NFA最小化; NFA最小化问题是PSPACE-complete

现在我们可以给出等价定义了:

Defnition(可区分的(Distinguishable);等价的(Equivalent))

如果存在某个能区分状态 s 与 t 的字符串, 则称 s 与 t 是可区分的 ; 否则, 称 s 与 t 是等价的

Defnition(字符串x区分状态 s 与 t)

如果分别从 s 与 t 出发, 沿着标号为 x 的路径到达的两个状态中只有1个是接受状态, 则称 x 区分了状态 s 与 t。

2.3.7 从DFA 到 词法分析器

我们直接以 a, abb, a*b+ 这三个词法单元为例

规定:

最前优先匹配: abb(比如 关键字)

最长优先匹配: aabb

先合并三个NFA, 构造 a|abb|a*b+ 的NFA

使用子集构造法 将 NFA 转化为等价的 DFA(并消除 "死状态")

注意: 要保留各个 NFA 的接受状态 信息, 并采用最前优先匹配原则

我们这个DFA中存在死状态, 下面蓝框框选的状态无法接受a

此时要和1.4 手写词法分析器联系起来

  • 模拟运行该 DFA, 直到无法继续为止(输入结束或状态无转移); 假设此时状态为 s
  • 若s为接受状态, 则识别成功;
  • 否则, 回溯 (包括状态与输入流 )至最近一次经过的接受状态, 识别成功;
    • 若没有经过任何接受状态,则报错(忽略第一个字符)
  • 无论成功还是失败, 都从初始状态开始继续识别下一个词法单元

这正是我们1.4 中的实现逻辑

来几个样例手玩一下:

  • x = a, 输入结束; 接受; 识别出a
  • x = abba, 状态无转移; 回溯成功; 识别出 abb
  • x = aaaa, 输入结束; 回溯成功; 识别出 a
  • x = cabb, 状态无转移; 回溯失败; 报错 c

特定于词法分析器的 DFA 最小化方法

前面我们最小化时, 初始为 {F, S - F}

但对于上面我们构造的DFA, 我们应该这样初始化:

{{0137, 7}, {247}, {8, 58}, {68}, { ∅ \empty ∅}}, 因为三个接受状态对应了三个不同的词法单元

相关推荐
Scott9999HH4 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
腻害兔4 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro 商城模块:从商品管理到交易引擎,50 张表撑起一整套电商系统
java·大数据·vue.js·产品经理·ai编程
码智社5 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海5 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖6 小时前
JDK 26 新特性详解
java·开发语言
马优晨7 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
人邮异步社区8 小时前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大9 小时前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
ttwuai9 小时前
Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验
开发语言·后端·golang
维天说9 小时前
CLI-Switch 2026年3月版历史设计:Hook、TTY 隔离与 JSON 状态
java·服务器·json