[编译原理]实现AQL Subset

本文详述了AQL Subset的实现过程,包括AQL的背景、子集定义、关键操作,以及项目中涉及的词法分析、语法分析、模式匹配等编译原理概念。通过实例展示了如何创建视图、提取正则表达式、模式匹配和输出结果,旨在帮助读者理解编译器的底层实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

AQL Subset的实现是我们学校软件工程编译原理课程的期末项目,其目的在于通过实现一个简单的编译器来加深学生对编译原理,如语言和文法、词法分析、语法分析等过程的理解,可以帮助学生深入认识编程的思想,编译器的底层实现。本文用于总结整个项目过程中遇到的难点,以及一点点个人经验。


什么是AQL

  • 全称:Annotation Query Language
  • 用于Text Analytics,可以从非结构化或半结构化的文本中提取结构化信息的语言
  • 语法类似SQL

什么是AQL Subset

  • AQL语法复杂,功能强大,实现的难度较高
  • AQL子集具有AQL的主要特点

名词定义

  • Token:以字母或者数字组成的无符号分隔的字符串,或者单纯的特殊符号,不包含空白符(blank)
    • eg:I am strong, and I don’t like eating light-blue lollipop.
      这里写图片描述
  • Span:由正则表达式提取出来的具有位置信息的字符串,可以是完整的Token,也可以是不完整的(半个或多个)
  • View:类似于数据库的Table,可以分为多个属性列,列中的行单元为Span

AQL子集

  • 主要操作:
    • create view (创建view)
      • extract regex (利用正则从文本中提取Span)
      • extract pattern (利用正则和view在原文本中匹配符合的模式)
      • select (在view中选列)
    • output view (格式化打印view)

AQL Subset 示例

  • 任务:将人名和人名对应的地名从文本中提取出来
    • 人名:大写字母开头的Token
    • 地名:两个以大写字母开头的Tokens,并且中间以逗号分隔,其中第二个单词是美国的州名
    • 文本:(下划线表示空格,会被忽略)
      Carter_from_Plains,Georgia,_Washington_from
      Westmoreland,_Virginia
    • 输入:
      这里写图片描述

General Method

这里写图片描述

从文本中提取出大写字母开头的单词(左闭右开)
Eg:
Carter(1,7)
Plains (13,19)
Georgia (21,28)
Washington (30,40)
Westmoreland (46,58)
Virginia (60,68)

从文本中提取出美国的州名
Eg:
Georgia (21,28)
Washington (30,40)
Virginia (60,68)

按照中间只隔了一个逗号,且后一个单词为州名即为一个地名的规则,拼接上述两步操作得到的字符串列表
Eg:
Plains, Georgia (13,28)
Georgia, Washington (21,40)
Westmoreland, Virginia (46,68)

大写字母开头的为人名:
Eg:
Carter(1,7)
Plains (13,19)
Georgia (21,28)
Washington (30,40)
Westmoreland (46,58)
Virginia (60,68)

人名与地名相对应的规则为相隔1-2个tokens,根据此规则拼接人名和地名的列表
Eg:

人名地名人名-地名
CarterPlains,GeorgiaCarter from Plains, Georgia
PlainsGeorgia,WashingtonPlains,Georgia,Washington
GeorgiaWestmoreland,VirginiaGeorgia无匹配
WashingtonWashington from Westmoreland,Virginia
WestmorelandWestmoreland无匹配
VirginiaVirginia无匹配

最终我们得到三个列表:

人名地名人名-地名
CarterPlains,GeorgiaCarter from Plains, Georgia
PlainsGeorgia,WashingtonPlains,Georgia,Washington
GeorgiaWestmoreland,VirginiaWashington from Westmoreland,Virginia
Washington
Westmoreland
Virginia

利用AQL

create view:regex语句
  • 提取第一个字母大写的token
create view Cap as
  extract regex /[A-Z][a-z]*/
    on D.text as Cap
  from Document D;

output:
这里写图片描述
这里写图片描述

  • 提取美国州名
create view Stt as
  extract regex /Washington|Georgia|Virginia/
    on D.text
    return group 0 as Stt
  from Document D;

output:
这里写图片描述
这里写图片描述

create view:pattern语句
create view Loc as
  extract pattern (<C.Cap>) /,/ (<S.Stt>)
    return group 0 as Loc
      and group 1 as Cap
      and group 2 as Stt
  from Cap C, Stt S;

output:
这里写图片描述
这里写图片描述

  • 拼接人名和地名,要求只相隔1-2个token的pattern语句:
create view PerLoc as
  extract pattern (<P.Per>) <Token>{1,2} (<L.Loc)
    return group 0 as PerLoc
      and group 1 as Per
      and group 2 as Loc
  from Per P, Loc L;

output:
这里写图片描述
这里写图片描述

create view: select语句
  • 从现成的view中选列
create view PerLocOnly as
  select PL.PerLoc as PerLoc
  from PerLoc PL;

这里写图片描述
这里写图片描述

create view: output语句
  • 输出语句
output view Cap;
output view Stt;
output view Loc;
output view Per;
output view PerLoc;
output view PerLocOnly;

这里写图片描述

项目流程

这里写图片描述

AQL Subset 语法

  • 递归下降的语法树结构
    这里写图片描述
    这里写图片描述
    这里写图片描述
    这里写图片描述
    这里写图片描述
    这里写图片描述
    这里写图片描述
    这里写图片描述
关键字

关键字如下,大小写敏感。
createviewasoutputselectfromextractregex
onreturngroupandTokenpattern

实现细节

  • Interpreter
    • Lexer(词法解释器)
      • 建议AQL的每个token都记录行号、列号,方便进行词法错误的提示
      • extract regex /正则表达式/, 使用了'/'来包含正则表达式,可以在词法分析时忽略前后的'/',将正则表达式整体作为一个token保存并返回给语法分析
    • Parser(语法分析器)
      • 递归下降的语法分析
      • 可自行调整语法的产生式,只要符合规定的语法规则
  • Text Tokenizer (文本提取器)
    • 从给定的文本数据中将一个个token分离出来
    • 划分规则:以非数字和非单词为间隔,除了空白符的一切字符或字符串都可以作为token,空白符被忽略
  • Pattern Matcher(模式匹配器)
    • 在语法分析过程中遇到特定pattern时执行的匹配操作
    • 可以当做对若干表进行拼接
    • Eg:
      这里写图片描述
      这里写图片描述
    • 关于<Token>{min,max},这里min和max指中间相隔了min到max个Token符合的模式
      这里写图片描述
  • output格式化输出
    • 将view的内容进行格式化输出
      这里写图片描述
  • 错误处理
    这里写图片描述
  • Regular Expression Engine(正则表达式引擎,已提供)
    • 提供已实现的regex.cpp及接口函数findall
    • regex.cpp实现了NFA方式的正则引擎
    • 支持特性:
      • 连接、选择、任意
        • ab, a|b, .
      • 重复(贪婪与非贪婪模式)
        • , +, ?, ?, +?, ??
      • 转义
        • \r, \n, \t
      • 字符类
        • [a-zA-Z0-9]
      • 捕获与非捕获
        • a(b)c, a(?:b)c
      • 全文多次匹配
        • [a-zA-Z0-9_]能够找到文本中所有的标识符,而不仅仅是匹配第一个找到的
  • 正则文法
regex → alt
alt → concat | alt '|' concat
concatrepeat | concat repeat
repeat → single
        | single '*' | single '*' '?'
        | single '+' | single '+' '?'
        | single '?' | single '?' '?'
single → '(' alt ')'
        | '(' '?' ':' alt ')'
        | '.' | CHAR | CHARCLASS
  • 正则函数接口:
    vector<vector<int>> findall(const char *regex, const char *content)
  • 函数功能
    • 返回正则表达式在文本中的所有匹配
  • 函数参数
    • regex:正则表达式
    • content: 文本
  • 返回结果
    • 返回正则表达式在文本中的所有匹配(外层vector)
    • 每一次匹配(内层vector)包含所有捕获块的文本匹配范围,其中每相邻两个整数表示一个捕获块范围(捕获块为圆括号包含起来的子表达式匹配到的内容)
    • 捕获块从0开始计数,第0个捕获块表示整个正则表达式,默认总是存在
    • 捕获块范围为左闭右开区间
举例

这里写图片描述

说明

  • 使用C/C++编写,允许使用STL
  • 代码干净整洁,变量命名合理

输入文件

  • *.aql为AQL程序文件
  • *.input为输入文本
  • *.output为输出文本

执行过程

这里写图片描述

References

具体实现

在详细分析了整个项目的要求之后,可以将其分为四个模块:

  • 1) Tokenizer:用以在输入文本中提取符合规则的Token,作为Parser的输入之一
  • 2) Lexer:用以提取输入的AQL语句中的关键字,作为Parser的输入之一
  • 3) Parser:根据Tokenizer和Lexer传进来的参数进行AQL语句的词法分析以及语义操作,并且要遵循递归下降的语法分析
  • 4) output:格式化输出表格

那么在划分好模块,清楚要实现的功能之后,要做的第一件事就是为每个模块设计好其数据结构。

Tokenizer(取词器)

首先从Tokenizer开始。根据项目要求,对于文本的一个Token,需要记录的属性有:在文本的开始和结束的位置以及内容。那么很容易可以初步设计出一个结构体:

struct TextToken {
    int begin;
    int end;
    string content;
};

有了TextToken的结构之后,我们需要将文本信息从文件中读取,存储到字符数组中,交由正则表达式引擎来帮助我们提取符合某一正则表达式的所有TextToken,保存在vector<vector<TextToken>>中。这里需要一个读取文件的函数:

char *readText(const char* filePath) {
    char* text;
    ifstream file;
    file.open(filePath);// open input file
    int length;        
    if (!file) {
        cout << "打开文件失败!" << endl;
        return NULL; 
    }
    file.seekg(0, std::ios::end);     // go to the end  
    length = file.tellg();           // report location (this is the length)  
    file.seekg(0, std::ios::beg);    // go back to the beginning  
    text = new char[length];    // allocate memory for a buffer of appropriate dimension  
    file.read(text, length);       // read the whole file into the buffer  
    file.close();
    return text;
}

以及用以返回TextToken集合的函数:

vector<vector<TextToken> > Tokenizer(char* regular, char* text) {
    vector< vector<int> > result;
    //调用正则表达式引擎
    result = findall(regular,text);

    vector<vector<TextToken> > return_result;
    if (result.size() == 0) {
        return return_result;
    } 
    //搜索所有tokens
    for (int i = 0 ; i < result[0].size() ; i+=2) {
        vector<TextToken> col;
        for (int j = 0 ; j < result.size() ; j++) {
            TextToken temp;
            temp.content = "";
            for (int k = result[j][i] ; k < result[j][i+1] ; k++) {
                temp.content += text[k];
            }
            temp.begin = result[j][i];
            temp.end = result[j][i+1];
            col.push_back(temp);
        }
        return_result.push_back(col);
    }
    return return_result;
}
Lexer(词法分析器)

当Lexer接收一条AQL语句时,它的职责就是从这个字符串中提取出AQL语言的非终结符序列、关键字等信息,加以处理之后将结果传给Parser。那么对于一整个Lexer类,我们可以将其细化为:

  • LexerToken:与TextToken类似,但它是记录AQL语句中的每一个划分出来的单元,包含Type、value、位置等信息;
struct LexerToken
{
    Type type;
    string value;
    int line;
    int sen_num;
    int begin;
    int end;
    LexerToken(string str, int line, int sen_num, int begin, int end, Type type) {
        this->value = str;
        this->line = line;
        this->sen_num = sen_num;
        this->begin = begin;
        this->end = end;
        this->type = type;
    }
    LexerToken() {
    }
};
  • Type:枚举类型,用以记录AQL语句中的所有保留字,可根据LexerToken中value的值来判断返回的枚举值
enum Type{
    CREATE, VIEW, AS, OUTPUT, SELECT, FROM, EXTRACT, REGEX, ON, RETURN,
    GROUP, AND, TOKEN, PATTERN, ID, DOT, REG, NUM, LESSTHAN, GREATERTHAN,
    LEFTBRACKET, RIGHTBRACKET, CURLYLEFTBRACKET, CURLYRIGHTBRACKET, SEMICOLON, COMMA, EMPTY
};

Type judgeType(string value) {
    if (value.compare("create") == 0) {
        return CREATE;
    }

    else if (value.compare("view") == 0) {
        return VIEW;
    }

    else if (value.compare("as") == 0) {
        return AS;
    }

    else if (value.compare("output") == 0) {
        return OUTPUT;
    }

    else if (value.compare("select") == 0) {
        return SELECT;
    }

    else if (value.compare("from") == 0) {
        return FROM;
    }

    else if (value.compare("extract") == 0) {
        return EXTRACT;
    }

    else if (value.compare("regex") == 0) {
        return REGEX;
    }

    else if (value.compare("on") == 0) {
        return ON;
    }

    else if (value.compare("return") == 0) {
        return RETURN;
    }

    else if (value.compare("group") == 0) {
        return GROUP;
    }

    else if (value.compare("and") == 0) {
        return AND;
    }

    else if (value.compare("Token") == 0) {
        return TOKEN;
    }

    else if (value.compare("pattern") == 0) {
        return PATTERN;
    }

    else if (value.compare(".") == 0) {
        return DOT;
    }

    else if (value.compare("<") == 0) {
        return  LESSTHAN;
    }

    else if (value.compare(">") == 0) {
        return GREATERTHAN;
    }

    else if (value.compare("(") == 0) {
        return LEFTBRACKET;
    }

    else if (value.compare(")") == 0) {
        return RIGHTBRACKET;
    }

    else if (value.compare("{") == 0) {
        return CURLYLEFTBRACKET;
    }

    else if (value.compare("}") == 0) {
        return CURLYRIGHTBRACKET;
    }

    else if (value.compare(";") == 0) {
        return SEMICOLON;
    }

    else if (value.compare(",") == 0) {
        return  COMMA;
    }
    else
    {
        if (value[0] == '/' && value[value.length()-1] == '/')
        {
            return REG;
        }
        int flag = 1;
        for (int i = 0; i < value.length(); i++)
        {
            if (value[i] > '9' || value[i] < '0') {
                flag = 0;
            }
        }
        if (flag == 1) {
            return NUM;
        }

        return ID;
    }
    return EMPTY;
}

那么,结合LexerToken和Type的内容,我们得到Lexer类的具体实现:对于每一条AQL语句字符串,在字符串中准确地根据分隔符划分出LexerToken,记录其各个属性,完整后保存到vector<LexerToken> tokens之中,再将tokens传给Parser,由Parser来完成它的工作。

#ifndef LEXER
#define LEXER 
#include<iostream>
#include<fstream>
#include<stdio.h>
#include<vector>
#include<string>
#include"LexerToken.h" 
#include"Type.cpp"
using namespace std;

bool isSeparator(char c) {
    return c == '.' || c == ',' 
        || c == ';' || c == '<' 
        || c == '>' || c == '(' 
        || c == ')' || c == '{' 
        || c == '}';
}

class Lexer {
 public:
    Lexer(){
    };

    void startLexing(const char *filePath) {
        tokens.clear();
        ifstream file;
        file.open(filePath);
        string str= "";
        int sen_num = 0;
        int line = 0;
        if (!file) {
            cout << "文件打开失败!" << endl;
            return;
        }
        while (getline(file, str))
        {
            string value = "";
            int first = 0;
            int last = 0;
            int length = str.size();
            int token_length = 0;

            for (int i = 0; i < length; i++) {
                while  (!((str[i] == ' ')||(str[i] == '\t')||(str[i] == '\r')||(str[i] == '\n'))  && i < length) {
                    if (str[i] == '/') {
                        value += str[i];
                        token_length++;
                        i++;
                        while (str[i] != '/') {
                        value += str[i];
                            token_length++;
                            i++;
                            }
                    }
                    else if (isSeparator(str[i])) {
                        break;
                    }
                    value += str[i];
                    token_length++;
                    i++;
                                        //cout << value << endl;
                }
                if (token_length) {
                    last = i;
                    first = i - token_length;
                    LexerToken t(value, line, sen_num, first, last, judgeType(value));
                    tokens.push_back(t);
                    value = "";
                    token_length = 0;
                }
                if  (isSeparator(str[i])) {
                    value += str[i];
                    token_length++;
                    last = i;
                    first = i - token_length;
                    LexerToken t(value, line, sen_num, first, last, judgeType(value));
                    tokens.push_back(t);
                    value = "";
                    token_length = 0;
                    if (str[i] == ';')sen_num++;    
                }
            }
            line++;
        }
        file.close();
    };

    ~Lexer() {
        if (tokens.size() != 0)
            tokens.clear();
    };

    vector<LexerToken> getTokens() {
        return tokens;
    };
 private:
    vector<LexerToken> tokens;
};
#endif
Parser(语法分析器)

整个项目最重要的部分就是Parser了,因为涉及到的逻辑远超于Tokenizer和Lexer的部分。语法分析器要担任的职责就是根据词法分析器传进来的LexerTokens,利用递归下降的语法分析方法,逐渐将整条AQL语句分成原子语句,使得向前看遇到的下一个LexerToken可以下降至抽象语法树(AST)的叶子节点,执行对应的逻辑。
所设计的Parser类如下:

#ifndef PARSER
#define PARSER
#include<iostream>
#include<vector>
#include<map>
#include "Lexer.h"
#include "LexerToken.h"
#include "Tokenizer.cpp"
#include "Type.cpp"
#include "printview.cpp"
using namespace std;

// 存取ducument的数据结构 
struct Doc {
    char* text;
};

class Parser{
 public:
    // 构造函数,两个参数:document文件+AQL文件。 
    Parser(const char* inputFile, const char* aqlFile){
        // 初始化position 
        position = 0;
        // 初始化document 
        document.text = readText(inputFile);
        // 词法分析 
        Lexer l;
        l.startLexing(aqlFile);
        lexer_tokens = l.getTokens();
        // 切割全文所有tokens 
        all_text_tokens = Tokenizer("([0-9a-zA-Z_])+|[^\\f\\n\\r\\t\\v\' \']", document.text)[0];
    };

    //读取要解析的LexerToken 
    LexerToken readLexerToken() {

    };

    //程序运行入口 
    void run() {

    };

    //解析aql语句 
    void aql_stmt() {

    };

    //解析create语句 
    void create_stmt() {

    };

    //解析select 
    map<string,vector<TextToken> > select_stmt() {

    };


    //解析regex语句 
    map<string, vector<TextToken> > regex_stmt() {

    };

    // 解析pattern语句 
    map<string, vector<TextToken> > pattern_stmt() {

    };

    vector<TextToken> pattern_expr(map<string, map<string, vector<TextToken> > > from_map,
                                     map<string, vector<TextToken> > &child_map) {

    }

    vector<TextToken> pattern_pkg(map<string, map<string, vector<TextToken> > > from_map) {

    }

    vector<TextToken> pattern_group(map<string, map<string, vector<TextToken> > > from_map, 
                            map<string, vector<TextToken> > &child_map) {

    }

    vector<TextToken> pattern_regex(string regex) {

    }

    vector<TextToken> pattern_token(vector<TextToken> col1, 
                            vector<TextToken> col2, 
                            int min, 
                            int max, 
                            map<string, vector<TextToken> > &child_map,
                            bool is_col1_group,
                            bool is_col2_group) {

    }
    //输入beigin和end,根据all_text_tokens,返回begin和end中间有几个token 
    int token_num_between(int begin, int end) {

    }

    //解析return语句 
    map<string, string> return_stmt() {

    };

    //解析from语句 
    map<string, map<string, vector<TextToken> > > from_stmt() {

    };

    //创建表名和列的键值对 
    void createView(string view_name, map<string, vector<TextToken> > view_columns) {

    };



    //返回列里的内容 
    vector<TextToken> getColumn(string view_name, string column_name) {
        }
    }

    int str2int(string s) {
        int i;
        stringstream ss;
        ss<<s;
        ss>>i;
        return i;
    }

    string int2str(int num) {
        stringstream ss;
        ss << num;
        string result = ss.str();
        return result;
    }

 private:
//  string view_name;
    vector<LexerToken> lexer_tokens;
    vector<TextToken> all_text_tokens; 
    Doc document;
    //表——string是表名,嵌套map里是各个列 
    map<string, map<string, vector<TextToken> > > views; 
    LexerToken this_token;
    int position;

};


#endif

这里遵循的AST结构如下(借用laiyuan同学的图),绿色的线表示函数调用,黑色表示终结符匹配:
这里写图片描述
整体思路大致相同,只是具体实现细节可能会因人而异,并且划分的细致程度也会有所区别。
比如:

aql_stmt → create_stmt ; | output_stmt ;

表明当输入一条aql时,判断下一个LexerToken的值

  • 如果Type == CREATE, 则进入create_stmt函数;
  • 如果Type == OUTPUT, 则进入output函数;
  • 否则语法错误,因为目前实现只有这两种。
    其他语句也大体如此,并且不同的语句会对应不同的处理逻辑,这里主要就是具体实现了,就不一一赘述。
Output(输出表格)

这部分相对于前面而言就比较简单了,主要实现准确提取view中的内容并格式化输出即可。


#include<iostream>
#include<map>
#include<vector>
#include<string>
#include<sstream>
#include "Tokenizer.cpp" 

using namespace std;


string int_to_string(int a) {
    stringstream ss;
    ss << a;
    return ss.str();
}

string outputwhiffletree(int length) {
    string s = "";
    for (int i = 0; i < length; i++) {
        s += "-";
    }
    return s;
}

string outputwhitespace(int length) {
    string s = "";
    for (int i = 0; i < length; i++) {
        s += " ";
    }
    return s;
}

void printView(map<string, vector<TextToken> > view, string view_name) {
    //map<string, map<string, vector<TextToken> > >::iterator it = view.begin();
    map<string, vector<TextToken> > col_information = view;
    map<string, vector<TextToken> >::iterator iter;
    map<string, vector<string> > output_string;
    vector<TextToken> col;
    vector<string> colname;
    int *max_length = new int[col_information.size()];
    int num = 0;
    for (iter = col_information.begin(); iter != col_information.end(); iter++) {
        string col_name = iter->first;
        col = iter->second;
        int max_string_length = 0;
        vector<string> col_string;
        for (int i = 0; i < col.size(); i++) {
            string content_string = col[i].content;
            string temp = " " + content_string + ":(" + int_to_string(col[i].begin) + "," + int_to_string(col[i].end)+") ";
            col_string.push_back(temp);
            if (max_string_length < temp.size())
                max_string_length = temp.size();
            temp.clear();
        }
        max_length[num] = max_string_length;
        num++;
        output_string.insert(pair<string, vector<string> >(col_name, col_string));
        colname.push_back(col_name);

    }

    cout << "View: "<< view_name << endl;
    if (col.size() == 0) {
        cout << "empty set" << endl;
        cout << endl;
        return;
    }


    string first_line = "+";
    for (int i = 0; i < col_information.size(); i++) {
        first_line += outputwhiffletree(max_length[i]) + "+";
    }
    cout<<first_line << endl;
    string sencond_line = "|";
    for (int i = 0; i < col_information.size(); i++) {
        sencond_line += " " + colname[i] + outputwhitespace(max_length[i] - colname[i].size()-1) + "|";
    }
    cout << sencond_line << endl;
    cout << first_line << endl;

    for (int i = 0; i < col.size(); i++) {
        cout << "|";
        for (int j = 0; j < col_information.size(); j++) {
            cout << output_string[colname[j]][i] + outputwhitespace(max_length[j] - output_string[colname[j]][i].size() ) << "|";
        }
        cout << endl;
    }

    cout << first_line << endl;
    cout << col.size() <<" rows in set" << endl;
    cout << endl; 
}

项目收获

作为编译原理期末project,AQL Subset确实是一个很好的项目。刚刚开始时会觉得无从下手,因为上课学习的知识还没有实践过。这一个project让我们很好地体会了编译器自顶向下、分治的思想,让我们学习到如何将一个大项目逐层分解,直到分成个人可以完成的模块,与队友分工合作。而我自己在队伍中承担的任务是为整个项目设定模块的数据结构、相关类的内容组成以及后期代码整合、debug等任务。在完成任务的过程中,确实认识到一个好的架构对于项目的影响是多么巨大。前期项目进度有些缓慢就是因为架构不够完备,在出现问题时才去对架构进行调整,因此而牵扯到的代码量还不小。好在与队友互相交流之后,有惊无险地完成了本次project。

项目代码链接

http://pan.baidu.com/s/1c0UApRq

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值