3. 使用Antlr4的Visitor模式实现一个简单的整数计算器

0. 序言

  • antlr支持Visitor和Listener两种设计模式,本文将介绍如何使用antlr4的visitor模式实现一个简单的整数计算器
  • Visitor模式通过double dispatch(concreteElement.accept(Visitor visitor) → \rightarrow visitor.visit(concreteElement)),实现了Element与操作的解耦。
  • Visitor模式的具体介绍,可以参考之前的博客:《访问者模式(visitor)模式》

1. Antlr4的语法文件

1.1 定义语法文件

  • 创建Calculator.g4文件,使用Antlr4的语法规则定义一个简单的、支持整数的四则运算的计算器

    grammar Calculator; // combined grammars that can contain both lexical and parser rules
    // parser rules,以小写字母开头
    prog: stat+; // 允许一次执行多个表达式
    
    // 定义语句,在每个可选方案后面,使用#开头定义label
    stat: expr EOF # PrintExpr
        | ID '=' expr EOF # Assign
        ;
    
    // 定义表达式,支持加减乘除、括号
    expr: expr op=(MUL|DIV) expr # MulDiv
        | expr op=(ADD|SUB) expr # AddSub
        | INT # Constant
        | ID # Variable
        | '(' expr ')' # Parentheses
        ;
    
    // lexer rules,以大写字母开头,一般全为大写
    // 定义运算符
    MUL: '*';
    DIV: '/';
    ADD: '+';
    SUB: '-';
    
    // 定义常量、变量
    INT: [0-9]+;
    ID: [a-zA-Z]+;
    
    // 定义可以skip的换行符、tab等
    WS: [ \t\r\n]+ -> skip;
    

1.2 编译语法文件,生成Java代码

  • 使用IDEA的antlr4插件,编译Calculator.g4,生成对应的Java代码
  • 最终将在src/main/java/com/sunrise/calculator目录下生成如下文件:
  • 基于visitor模式实现计算器,主要通过继承并重写CalculatorBaseVisitor中的方法来实现

2. 重要的类或接口

  • 在介绍如何基于visitor模式实现计算器前,先简单介绍将要使用到的重要类或接口
  • 如果读者觉得铺垫太多,可以直接跳到第3小节,Visitor模式的代码实现

2.1 visitor接口或类

  • Calculator.g4编译后,有两个以Visitor为结尾的Java文件:CalculatorVisitor接口、CalculatorBaseVisitor

CalculatorVisitor接口

  • CalculatorVisitor接口的代码如下:

    • 继承了ParseTreeVisitor接口,并为Calculator.g4中parse rule对应的parse tree创建了相应的visit()方法
    • 泛型通配符T,表示visit操作的返回值类型,如果没有返回值,可以使用Void作为返回值类型
    public interface CalculatorVisitor<T> extends ParseTreeVisitor<T> {
    	// 访问由CalculatorParser.prog()方法生成的parse tree,即访问prog这个parser rule对应的parse tree
    	T visitProg(CalculatorParser.ProgContext ctx);
    	// 访问CalculatorParser.stat()方法生成的、label为PrintExpr的解析树
    	T visitPrintExpr(CalculatorParser.PrintExprContext ctx);
    	// 访问CalculatorParser.stat()方法生成的、label为Assign的解析树
    	T visitAssign(CalculatorParser.AssignContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Variable的解析树
    	T visitVariable(CalculatorParser.VariableContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为MulDiv的解析树
    	T visitMulDiv(CalculatorParser.MulDivContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为AddSub的解析树
    	T visitAddSub(CalculatorParser.AddSubContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Constant的解析树
    	T visitConstant(CalculatorParser.ConstantContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Parentheses的解析树
    	T visitParentheses(CalculatorParser.ParenthesesContext ctx);
    }
    
  • 注意: 使用Void作为返回值类型,方法的返回值应该定义为null,而不能像void方法一样,使用return;或者无return语句

    Callable<Void> callable = new Callable<Void>() {
        @Override
        public Void call() {
            System.out.println("Hello!");
            return null;
        }
    };
    

ParseTreeVisitor接口

  • 由antlr-runtime提供的、访问parse tree的基础接口,其中的每个方法都会返回类型为T的、用户自定义的操作结果(user-defined result of the operation

    public interface ParseTreeVisitor<T> {
    	// visit一棵parse tree的入口方法
    	T visit(ParseTree tree);
        // 访问一个节点的所有孩子节点,这里的孩子特指儿子节点,不包括孙子节点
        // parse tree由一系列的节点(RuleNode)组成,一个节点代表的不只有节点本身,还有其下的子节点
    	T visitChildren(RuleNode node);
    	// 访问terminal节点,是parse tree的叶子节点,也是词法解析后得到的tokens
    	T visitTerminal(TerminalNode node);
    	// 访问一个error节点
    	T visitErrorNode(ErrorNode node);
    
    }
    

抽象类AbstractParseTreeVisitor

  • AbstractParseTreeVisitor是一个抽象类,提供了ParseTreeVisitor接口的默认实现,关键点:

    • visit()方法:内含accept()方法,实现了double dispatch逻辑
    • visitChildren()方法:基于visitor模式、使用accept()方法实现对所有孩子节点的访问
    public abstract class AbstractParseTreeVisitor<T> implements ParseTreeVisitor<T> {
    	// 以visit模式访问parse tree时,通过ParseTree.accept(this) --> ParseTreeVisitor.visit(this)实现double dispatch
    	@Override
    	public T visit(ParseTree tree) {
    		return tree.accept(this);
    	}
    	
    	@Override
    	public T visitChildren(RuleNode node) {
    		T result = defaultResult(); // result的初始值为null
    		int n = node.getChildCount();
    		for (int i = 0; i < n; i++) {
    			// 访问孩子节点前,先判断是否可以访问孩子节点。若返回false表示不能访问,则直接退出循环
    			if (!shouldVisitNextChild(node, result)) { 
    				break;
    			}
    			// 获取孩子节点,通过accept()方法,实现孩子节点及其所有子节点的访问,从而获得操作结果
    			ParseTree c = node.getChild(i);
    			T childResult = c.accept(this);
    			result = aggregateResult(result, childResult); // 聚合结果,默认将childResult作为最新的result
    		}
    
    		return result;
    	}
    
    	@Override
    	public T visitTerminal(TerminalNode node) {
    		return defaultResult();
    	}
    
    	@Override
    	public T visitErrorNode(ErrorNode node) {
    		return defaultResult();
    	}
    	
    	protected T defaultResult() {
    		return null;
    	}
    	// 一个RuleContext可能拥有多个孩子节点,需要对每个孩子节点的操作结果做聚合
    	protected T aggregateResult(T aggregate, T nextResult) {
    		return nextResult;
    	}
    	// 返回值true,表示可以继续访问孩子节点;false,则表示需要立即停止访问孩子节点
    	protected boolean shouldVisitNextChild(RuleNode node, T currentResult) {
    		return true;
    	}
    }
    
  • 疑问: AbstractParseTreeVisitor为何被定义为抽象类?

    • AbstractParseTreeVisitor对ParseTreeVisitor接口的所有方法都进行了实现,且没有定义任何的abstract方法,完全可以将其定义一个普通类
    • 不靠谱的猜测: AbstractParseTreeVisitor是所有Visitor类的父类,将其定义为抽象类完全OK 😂 😂 😂

CalculatorBaseVisitor类

  • CalculatorBaseVisitor类,继承了抽象类AbstractParseTreeVisitor,并实现了CalculatorVisitor
  • 准确地说,CalculatorBaseVisitor基于AbstractParseTreeVisitor.visitChildren()方法,为CalculatorVisitor接口中所有的方法提供了一个默认实现
    @SuppressWarnings("CheckReturnValue")
    public class CalculatorBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements CalculatorVisitor<T> {
    	@Override public T visitProg(CalculatorParser.ProgContext ctx) { return visitChildren(ctx); }
    	
    	@Override public T visitPrintExpr(CalculatorParser.PrintExprContext ctx) { return visitChildren(ctx); }
    	... // 其他方法的实现类似,这里不予展示
    }
    

Generated visitors implement this interface and the XVisitor interface for grammar X.

  • 这是ParseTreeVisitor接口注释中的原话,大意:基于grammar X生成的Visitor类,都将实现ParseTreeVisitor接口和对应的XVisitor接口
  • grammar Calculator为例:
    • Visitor类就是CalculatorBaseVisitor,XVisitor接口就是CalculatorVisitor接口
    • CalculatorBaseVisitor对ParseTreeVisitor接口的实现,则是依靠AbstractParseTreeVisitor这个抽象类完成的

2.2 Parser

  • 介绍Visitor类时,出现的最多的则是CalculatorParser类,它继承了抽象类Parser,是实现parser rule解析的关键类
    public class CalculatorParser extends Parser 
    

2.2.1 ParserRuleContext

  • CalculatorParser类中包含多种ParserRuleContext,它们都是public static类型的inner class,如AddSubContextProgContext
  • 每个parser rule或者加了label的rule element,都将对应一个ParserRuleContext
  • 或者说,每个ParserRuleContext都对应语法解析树中的parser rule或带标签的rule element节点
  • 以ProgContext的层次图为例,ParserRuleContext的类层次关系如下:

2.2.2 ParserRuleContext的关键方法

  • 以ProgContext为例,对应的prog规则只有一个rule element,因此未使用label

  • ProgContext的代码如下,内含获取child节点(StatContext)的getter方法,重写了RuleContext或ParserRuleContext中的一些关键方法

    @SuppressWarnings("CheckReturnValue")
    public static class ProgContext extends ParserRuleContext {
    	// 获取孩子节点的getter方法
    	public List<StatContext> stat() {
    		return getRuleContexts(StatContext.class);
    	}
    	public StatContext stat(int i) {
    		return getRuleContext(StatContext.class,i);
    	}
    	public ProgContext(ParserRuleContext parent, int invokingState) {
    		super(parent, invokingState);
    	}
    	// 重写RuleContext.getRuleIndex()方法,返回prog的index,值为0
    	@Override public int getRuleIndex() { return RULE_prog; }
    	// 重写ParserRuleContext中,与listener模式有关的、进入或退出rule的方法
    	@Override
    	public void enterRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).enterProg(this);
    	}
    	@Override
    	public void exitRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).exitProg(this);
    	}
    	// 重写RuleContext.accept()方法,是实现visitor模式的关键
    	@Override
    	public <T> T accept(ParseTreeVisitor<? extends T> visitor) {
    		if ( visitor instanceof CalculatorVisitor ) return ((CalculatorVisitor<? extends T>)visitor).visitProg(this);
    		else return visitor.visitChildren(this);
    	}
    }
    
  • 对于stat这种包含多个rule element,且使用了label的parser rule,会先基于rule创建一个Conext,然后再创建每个rule element的Context

    @SuppressWarnings("CheckReturnValue")
    public static class StatContext extends ParserRuleContext {
    	public StatContext(ParserRuleContext parent, int invokingState) {
    		super(parent, invokingState);
    	}
    	@Override public int getRuleIndex() { return RULE_stat; }
     
    	public StatContext() { }
    	public void copyFrom(StatContext ctx) {
    		super.copyFrom(ctx);
    	}
    }
    @SuppressWarnings("CheckReturnValue")
    public static class AssignContext extends StatContext {
    	public TerminalNode ID() { return getToken(CalculatorParser.ID, 0); }
    	public ExprContext expr() {
    		return getRuleContext(ExprContext.class,0);
    	}
    	public AssignContext(StatContext ctx) { copyFrom(ctx); }
    	@Override
    	public void enterRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).enterAssign(this);
    	}
    	@Override
    	public void exitRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).exitAssign(this);
    	}
    	@Override
    	public <T> T accept(ParseTreeVisitor<? extends T> visitor) {
    		if ( visitor instanceof CalculatorVisitor ) return ((CalculatorVisitor<? extends T>)visitor).visitAssign(this);
    		else return visitor.visitChildren(this);
    	}
    }
    

2.3 TerminalNode

  • 所谓TerminalNode,就是parse tree的叶子节点,也是经过词法解析后得到的一系列tokens

  • 这点从CalculatorParser中ParserRuleContext的定义也能看出

  • 按照某种方式遍历parse tree,最终将访问TerminalNode,并从TerminalNode获取一些需要的信息或值

3. 基于visitor模式实现计算器

3.1 为什么需要自定义visitor ?

  • 使用stat这个paser rule,构建1+2对应的parse tree

  • 按照箭头所示的方向遍历这棵树,需要从TerminalNode获取常量的值和运算符,才能完成表达式的计算

  • 然而,AbstractParseTreeVisitorvisitTerminal()方法的默认实现,是直接返回null

  • 若使用visit()方法对上面的parse tree进行访问,按照CalculatorBaseVisitor的访问逻辑,最终TerminalNode均为null,更别提按照语义进行加法计算

    // 遍历stat对应的语法解析树
    CalculatorBaseVisitor<Integer> visitor = new CalculatorBaseVisitor<>();
    visitor.visit(stat);
    
  • 因此,想要实现计算器逻辑,则必须按照实际需求重写CalculatorBaseVisitor中的方法

3.2 自定义visitor

  • 继承CalculatorBaseVisitor,并设置泛型类型为Integer,之后对所有ParserRuleContext的访问,都将返回Integer类型的值

    public class CalculatorVisitorImpl extends CalculatorBaseVisitor<Integer> {
        private final HashMap<String, Integer> variables = new HashMap<>();
    
        // 无需重写该方法,多个孩子节点,通过CalculatorBaseVisitor.visitProg() --> AbstractParseTreeVisitor.visitChildren()遍历整棵树
        @Override
        public Integer visitProg(CalculatorParser.ProgContext ctx) {
            return super.visitProg(ctx);
        }
    
        @Override
        public Integer visitPrintExpr(CalculatorParser.PrintExprContext ctx) {
            // 通过visit()遍历ExprContext,获取表达式的值并打印计算结果
            Integer result = visit(ctx.expr());
            // 如果为constant类型的表达式,则直接打印;否则,打印原始的表达式或变量名
            if (ctx.expr() instanceof CalculatorParser.ConstantContext) {
                System.out.println(result);
            } else {
                System.out.printf("%s = %d\n", ctx.expr().getText(), result);
            }
            return result;
        }
    
        @Override
        public Integer visitAssign(CalculatorParser.AssignContext ctx) {
            // 访问ID类型的TerminalNode,获取变量名
            String variable = ctx.ID().getText();
            // 通过visit()遍历ExprContext,获取表达式的值,从而为变量赋值
            Integer value = visit(ctx.expr());
            variables.put(variable, value);
            return value;
        }
    
        @Override
        public Integer visitVariable(CalculatorParser.VariableContext ctx) {
            String variable = ctx.getText();
            // 从内存中获取变量的值,没有返回0
            return variables.getOrDefault(variable, 0);
        }
    
        @Override
        public Integer visitMulDiv(CalculatorParser.MulDivContext ctx) {
            // 通过visit()遍历左右两个ExprContext,从而获取左右操作数的值
            Integer left = visit(ctx.expr(0));
            Integer right = visit(ctx.expr(1));
            // 根据运算符,进行相应的计算并返回计算结果
            if (ctx.op.getType() == CalculatorParser.MUL) {
                return left * right;
            }
            return left / right;
        }
    
        @Override
        public Integer visitAddSub(CalculatorParser.AddSubContext ctx) {
            Integer left = visit(ctx.expr(0));
            Integer right = visit(ctx.expr(1));
            if (ctx.op.getType() == CalculatorParser.ADD) {
                return left + right;
            }
            return left - right;
        }
    
        @Override
        public Integer visitConstant(CalculatorParser.ConstantContext ctx) {
            // 直接获取常量的值,原本为String,通过Integer.valueOf()转为Integer
            return Integer.valueOf(ctx.INT().getText());
        }
    
        @Override
        public Integer visitParentheses(CalculatorParser.ParenthesesContext ctx) {
            // 括号表达式,需要返回括号内表达式的值。通过visit()访问ExprContext,获取表达式的值
            return visit(ctx.expr());
        }
    }
    

3.3 使用自定义的visitor遍历parse tree

  • 使用visitor遍历parse tree,需要分为以下几步:

    1. 输入字符流,CalculatorLexer将字符流转为tokenStream
    2. 输入tokenStream,CalculatorParser基于指定的parser rule,将tokenStream转为对应的parse tree
    3. 使用自定义的visitor遍历stat对应的parse tree,该parse tree对应的操作可能是为变量赋值,也可能是打印表达式的计算结果
    public static void main(String[] args) {
        // 多个stat,只能识别到第一个stat,属于PrintExpr
        String input = "1 + 2 * 3\n"
                + "b = (5 - 2) / 2\n"
                + "c = a + b\n"
                + "c\n";
        // 1. 词法分析,解析出token;这时tokenStream中还未填充token,因为词法分析由语法分析触发
        // Normally, the Parser is responsible for initiating the lexing of the input stream
        CharStream charStream = CharStreams.fromString(input);
        CalculatorLexer lexer = new CalculatorLexer(charStream);
        CommonTokenStream tokenStream = new CommonTokenStream(lexer);
    
        // 2. 基于tokenStream进行语法分析,得到parse tree
        CalculatorParser parser = new CalculatorParser(tokenStream);
        ParseTree stat = parser.stat();
    	// 打印语法解析树,但并不直观
        System.out.printf("stat对应的语法解析树如下:\n%s\n", stat.toStringTree(parser));
    
        // 3. 使用自定义的visitor遍历parse tree
        CalculatorBaseVisitor<Integer> visitor = new CalculatorVisitorImpl();
        visitor.visit(stat);
    }
    
  • 执行结果如下:

  • 对输入字符流进行语法分析后,只能识别出1 + 2 * 3这个PrintExpr,与ANTLR Preview得到的结果一致

  • 同时,对parse tree的visit遍历,打印的表达式计算结果也符合预期

  • 注意: main()是基于stat对字符流进行分析,得到是一个不太完善的计算器,要想得到一个功能完备的计算器,可以基于prog对字符流进行分析

    CalculatorBaseVisitor<Integer> visitor = new CalculatorVisitorImpl();
    visitor.visit(prog);
    

3.4 visitor模式的遍历过程详解

  • 使用visitor模式遍历stat规则的parse tree,其大致遍历路径如下图所示,实际就是对一棵树进行DFS(深度优先搜索)
    在这里插入图片描述
  • 完整的遍历路径如图所示,可见parse tree的遍历离不开以下要素:
    1. visit()方法,确切地说是AbstractParseTreeVisitor.visit()方法,是parse tree遍历的入口方法
      • parse tree可能是一棵完整的tree,如上图以PrintExprContext开始的tree
      • 也可能是一棵子树,如上图以MulDivContext开始的tree
    2. ctx.accept(visitor) → \rightarrow visitor.visitCtx(ctx)实现了double dispatch,将对某个ParserRuleContext(简写为Ctx)的visit导向了具体的Visitor实现
    3. visitCtx()方法中,会根据情况选择visit(孩子节点),还是visitChildren(),以实现树的DFS
      • 如果Ctx需要遍历的孩子节点个数是固定的,如MulDivContext有2个孩子节点需要遍历:左expr、右expr,这时直接visit(孩子节点)即可
      • 如果Ctx的孩子节点个数不固定,则需要使用visitChildren()依次遍历每个孩子节点。对ProgContext的孩子节点的遍历,实际就是采用的visitChildren()方法
  • 注意: 上图,对MulDivContext的visit流程有所省略,直接到了visit孩子节点这一步

3.5 对ProgContext的visit

  • 输入字符流如左边所示,使用prog规则进行解析,将获得如右边所示的parse tree
  • 这时,visit该parse tree,在visit根节点ProgContext时,最终将调用visitChildren()方法

4. 后记

  • 使用visitor模式实现计算器,有如下感受:
    • 所谓的ParseTree实际是包含父节点、子节点和payload的tree node,这些tree node相互关联形成了parse tree
    • 基于Antlr4的visitor模式实现某个需求,关键点:继承BaseVisitor并按需重写其中的方法
    • visit一棵parse tree的过程非常复杂,但离不开入口方法visit()accept() → \rightarrow visitCtx()的double dispatch,visitCtx() → \rightarrow visit()visitCtx() → \rightarrow visitChildren()的DFS链。
    • 结合ANLTR Preview提供的parse tree,并以debug方式了解遍历过程,是最佳实践
  • TODO:
    • 学习如何打印词法分析得到的tokens,并体会Normally, the Parser is responsible for initiating the lexing of the input stream这句话的含义
    • 学习如何使用listener模式实现一个简单的整数计算器
### 回答1: org.antlr.v4.runtime.* 的 Maven 依赖如下: ``` <dependency> <groupId>org.antlr</groupId> <artifactId>antlr4</artifactId> <version>4.8</version> </dependency> ``` 需要注意的是,请确保使用最新版本,因为ANTLR版本在不断更新。 ### 回答2: org.antlr.v4.runtime是一个Java库,它是ANTLR(ANother Tool for Language Recognition)工具的一部分。ANTLR一个用于构建语言识别工具的框架,通过使用ANTLR工具,我们可以定义自己的语言规则,并生成相应的词法分析器和语法分析器。 为了在我们的Java项目中使用org.antlr.v4.runtime库,我们可以使用Maven来管理它的依赖。Maven是一个流行的项目管理工具,它可以帮助我们自动地下载和引入项目所需的外部库。 要在Maven中引入org.antlr.v4.runtime库,我们需要在项目的pom.xml文件中添加相应的依赖项。以下是一个示例的pom.xml文件片段,演示了如何添加org.antlr.v4.runtime依赖: <dependencies> <dependency> <groupId>org.antlr</groupId> <artifactId>antlr4-runtime</artifactId> <version>4.9.2</version> </dependency> </dependencies> 在这个示例中,我们指定了用于org.antlr.v4.runtime库的依赖项。groupId是组织的唯一标识符,artifactId是库的唯一标识符,version是库的版本号。你可以根据实际情况调整这些值来匹配你所使用的org.antlr.v4.runtime库的版本。 当我们执行Maven构建时,Maven将根据这个pom.xml文件自动下载org.antlr.v4.runtime库及其所需的任何其他依赖项,并将它们添加到项目的类路径中,以便我们可以在我们的代码中使用这个库。 总之,通过使用Maven,我们可以轻松地将org.antlr.v4.runtime库添加到我们的Java项目中,以便我们可以使用ANTLR工具来进行语言识别和分析。 ### 回答3: org.antlr.v4.runtime.*是一个在Maven项目中常见的依赖项。Maven是一个用于构建和管理Java项目的工具,它使用pom.xml配置文件来管理项目的依赖项。 antlr是一种非常流行的语法解析器生成工具,它可以根据预定义的语法规则生成相应的解析器。org.antlr.v4.runtime.*是ANTLR版本4的Java运行时库的包名。这个包包含了ANTLR的运行时环境和必需的类,用于解析和处理由ANTLR生成的解析树。 如果想在Maven项目中使用org.antlr.v4.runtime.*依赖,需要在pom.xml文件中添加相应的配置。在<dependencies>标签下,添加以下内容: ``` <dependency> <groupId>org.antlr</groupId> <artifactId>antlr4-runtime</artifactId> <version>4.8</version> </dependency> ``` 这个配置会告诉Maven去下载并导入org.antlr.v4.runtime.*的相关依赖,包括antlr4-runtime库及其相关的依赖项。 一旦配置完成,Maven会自动从中央仓库或其他配置的仓库下载这些依赖,并将其添加到项目的classpath中。这样,就可以在项目中使用org.antlr.v4.runtime.*提供的类和功能了。 总之,org.antlr.v4.runtime.*是一个Maven项目中常见的依赖项,用于解析和处理由ANTLR生成的解析树。通过在pom.xml中添加相应的依赖配置,就可以使用这些类和功能,并让Maven帮助我们管理这些依赖。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值