Let’s Build A Simple Interpreter 7

前言

原文链接:https://ruslanspivak.com/lsbasi-part7/

之前的那几小结,我们都是把interpreter的代码和parser的代码混在一起,而且interpreter在parser识别出一个如加减乘除之类 的特定的语言结构(language construct)后,就会立刻对它进行求值。这种 interpreter 被称为 语法导向解释器(syntax-directed interpreter)。

他们通常在输 入上做一个 pass 且只适合基础的语言应用。

为了分析更复杂的编程语言 Pascal 的结构, 我们需要建立一个 中间表示 (intermediate representation, IR)

parser 会 负责构建 IR

interpreter 会用来解释由 IR 所代表的输入。

事实证明树是一个表示 IR 非常合适的数据结构。关于数的一些术语terminology:

  • 树是一个包含一个或多个结点组成的层次数据结构。
  • 树有一个根结点,就是顶部结点。
  • 除根结点外的所有结点有唯一 一个父结点
  • 下图中结点为*的是一个父结点。结点为 2 和 7 的是它的子结点;子结点从左到右排序。
  • 没有子结点的结点称为叶子结点
  • 有一个或多个子结点的非根结点被称为中间结点
  • 子结点也可以是完全子树。下图中结点+的左子树(结点为*)就是一个有自己子结点的 完全子树。
  • 在计算机科学中我们把树倒过来画,根结点在最上边,分枝向下生长。

下面是表达式 2 * 7 + 3 的带有解释的树形表示:

lsbasi_part7_tree_terminology

本系列中我们会用到的 IR 被称为 抽象语法树 (abstract-syntax tree, AST)。但在深 入了解 AST 之前让我们简单聊聊 解析树 (parse tree)。

尽管我们不会在解释器和编译 器中用到解析树,但它会通过可视化 parser 执行轨迹的方法,加深你对 parser 如何解释 输入的理解。我们也会将它和 AST 做比较,来表明为什么 AST 比解析树更适合用来做 IR。

那么,什么是解析树呢?

  • 解析树(有时叫做 具体语法树concrete syntax tree )是一个根据我们的语法定义来 表示一门语言的句法结构的树形结构。它基本上展示了你的 parser 如何识别语言结构或者, 换句话说,它展示了你语法的开始符号怎么派生出该编程语言中一个特定的字符串的。

parser 的调用栈隐式地代表了一个解析树,且当parser 在试图识别一个特定的语言结构时,解析树 就会自动地在内存中构建出来。下面是表达式 2 * 7 + 3 的解析树:

lsbasi_part7_parsetree_01

在上面的图片中可以看到:

  • 解析树记录了 parser 用来识别输入的一系列规则。
  • 解析树的根结点的标签是语法的开始符号(start symbol)。
  • 每个中间结点表示一个非终结符(non-terminal),代表应用了一条语法规则,像我们的情况里的 expr, termfactor.
  • 每个叶子结点代表了一个 token.

我们不会手动构建解析树且在我们的解释器中用到它,但解析树可以通过可视化 调用过程帮助我们理解 parser 怎么解释输入。

你可以使用一个名为 genptdot.py 的小应用(我很快写完用来帮助你的),来查看不同的 算术表达式看起来什么样。要使用这个应用你首先需要安装 Graphviz包,然后运行下面的 命令,你可以打开生成的图片文件 parsetree.png 查看你从命令行传入的表达式的解析树:

1
2
$ python genptdot.py "14 + 2 * 3 - 6 / 2" > \
  parsetree.dot && dot -Tpng -o parsetree.png parsetree.dot

下面是由表达式 14 + 2 * 3 - 6 / 2 生成的图片 parsetree.png:

lsbasi_part7_genptdot_01

parser

抽象语法树(AST)

现在我们来聊聊抽象语法树(AST)。它是在余下的文章中会大量用到的中间表示(IR)。它是 我们的解释器和未来编译器项目的核心数据结构。

让我们以把表达式 2 * 7 + 3 的 AST 和解析树放在一起看来开始我们的讨论:

lsbasi_part7_ast_01

从上面的图片中可以看出,AST 抓住了输入的精髓且更小。

AST 和解析树最主要的区别有

抽象语法树是什么

抽象语法树(AST)是表示一个语言结构的抽象句法结构的树形表示,它的中间结点和根结点代表了一个操作符,子结点代表了该操作符的操作数。

看一下AST 与解析树对比,显现他的紧凑性:

lsbasi_part7_ast_02

如何将操作符的优先级(precedence)编码进 AST 呢?

为了把操作符优先级编码进 AST,即,为了表示“X 在 Y 之前发生”你只需要在树中把 X 放在低于 Y 的位置。你在前面 的图片中已经见过到了。

lsbasi_part7_astprecedence_01

代码实现AST

好了,让我们写些代码来实现不同的 AST 结点类 并修改我们的 parser 来生成包含这些结点的 AST 树:

回忆一下表达式 2 * 7 + 3 的 AST。我们会在代码中手工创建该表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> from spi import Token, MUL, PLUS, INTEGER, Num, BinOp
>>>
>>> mul_token = Token(MUL, '*')
>>> plus_token = Token(PLUS, '+')
>>> mul_node = BinOp(
...     left=Num(Token(INTEGER, 2)),
...     op=mul_token,
...     right=Num(Token(INTEGER, 7))
... )
>>> add_node = BinOp(
...     left=mul_node,
...     op=plus_token,
...     right=Num(Token(INTEGER, 3))
... )

以下是在新定义的结点类下 AST 的样子。下面的图片也遵循了上面手工创建的过程:

lsbasi_part7_astimpl_01

parser 代码

下面是我们修改过的 parser 代码,在识别输入(算术表达式)时建立和返回一个 AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AST():
    pass

class BinOp(AST):
    def __init__(self, left, op, right):
    self.left  = left
    self.token = self.op = op
    self.right = right

class Num(AST):
    def __init__(self, token):
    self.token = token
    self.value = token.value

class Parser():
    def __init__(self, lexer):
    self.lexer = lexer
    # set current token to the first token from the input
    self.current_token = self.lexer.get_next_token()

    def error(self):
    raise Exception('Invalid syntax')

    def eat(self, token_type):
    # compare the current token type with the passed token
    # type and if they match then "eat" the current token
    # and assign the next token to the self.current_token,
    # otherwise raise an exception.
    if self.current_token.type == token_type:
        self.current_token = self.lexer.get_next_token()
    else:
        self.error()

    def factor(self):
    """factor : INTEGER | LPAREN expr RPAREN"""
    token = self.current_token
    if token.type == INTEGER:
        return Num(token)
    elif token.type == LPAREN:
        self.eat(LPAREN)
        node = self.expr()
        self.eat(RPAREN)
        return node

    def term(self):
    """term : factor ((MUL | DIV) factor)*"""
    node = self.factor()

    while self.current_token.type in (MUL, DIV):
        token = self.current_token
        if token.type == MUL:
        self.eat(MUL)
        elif token.type == DIV:
        self.eat(DIV)

        node = BinOp(left=node, op=token, right=self.factor())

    return node

    def expr(self):
    """
    expr   : term ((PLUS | MINUS) term)*
    term   : factor ((MUL | DIV) factor)*
    factor : INTEGER | LPAREN expr RPAREN
    """
    node = self.term()

    while self.current_token.type in (PLUS, MINUS):
        token = self.current_token
        if token.type == PLUS:
        self.eat(PLUS)
        elif token.type == MINUS:
        self.eat(MINUS)

        node = BinOp(left=node, op=token, right=self.term())

    return node

    def parse(self):
    return self.expr()

让我们看一些算术表达式的 AST 的构建过程。

如果你看了上面的 parser 代码,可以看到它建立一个 AST 中的结点的时,把变量 node 的当前值做为 BinOp 结点的左子结点,把对 termfactor 调用的返回结果做为它 的右子结点,这实际上就是把结点推向左边,下面表达式 1 +2 + 3 + 4 + 5 的树结构就是 这种情况的一个好例子。下面是 parser 如何一步步地构建表达式 1 + 2 + 3 + 4 + 5 的 AST 的图形表示:

lsbasi_part7_astimpl_02

遍历

好了,下面是表达式 2 * 7 + 3 的 AST:

lsbasi_part7_ast_walking_01

你怎么遍历这个树并恰当地对它所代表的表达式进行求值呢?

你可以使用后序遍历*postorder traversal* (深度优先遍历depth-first traversal 的一个特例) 。

下面是后序遍历的伪代码,其中 << postorder actions >>是一些操作的占位符,如 BinOp 结点的加减乘除操作或 Num 结点返回整数的简单操作:

lsbasi_part7_ast_visit_postorder

lsbasi_part7_ast_walking_02

三种深度优先遍历

为了完整起见,三种深度优先遍历的方式:先序遍历,中序遍历和后序遍历。这 些遍历方式名字的来自于遍历代码中操作的位置:

lsbasi_part7_ast_visit_generic

有时你可能需要在所有地方(先序,中序和后序)都执行一些操作。你会在本文的源代码仓 库中找到一些例子。

Interpreter

用代码来遍历和解释由 parser 建立的抽象语法树

好了,让我们写一些代码来遍历和解释由 parser 建立的抽象语法树,好吗?

下面是实现了访问者模式的源代码:

1
2
3
4
5
6
7
8
class NodeVisitor():
    def visit(self, node):
        method_name = 'visit_' + type(node).__name__
        visitor = getattr(self, method_name, self.generic_visit)
        return visitor(node)
#generic_visit 是一个备用函数,它会抛出一个异常来表示它遇到了一个实现类中没有相 应 #visit_NodeType 方法的结点。
	def generic_visit(self, node):
        raise Exception('No visit_{} method'.format(type(node).__name__))

NodeVisitor一个结点访问器的基类:它遍历抽象语法树并为找到的每个节点调用一个访问器函数。(这个函数可能会返回一个由visit()方法转发 的值)。This class is meant to be subclassed, with the subclass adding visitor methods.

成员函数:

  • 1️⃣ visit(node):访问一个结点。它默认调用self.visit_classname(其中的classname是结点类的名
    字,或者如果这个方法不存在时,classname就是 generic_visit() )的方法。
  • 2️⃣ generic_visit(node):这个访问器(visitor)对结点的所有子节点调用visit()。
    【注意:只有这个访问器调用generic_visit() 或者访问它本身,否则自定义访问器的结点的子节点将不会被访问】

1
getattr(object, name[, default])

官方文档中说这个函数作用是返回对象的一个属性,第一个参数是对象实例obj,name是个字符串,是对象的成员函数名字或者成员变量,default当对象没有这个属相的时候就返回默认值,如果没有提供默认值就返回异常。

如:

  1. 提供不默认写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> class Test(object):
...     def func(self):
...             print 'I am a test'
...
>>> test = Test()  # 实例化一个对象
>>> func = getattr(test, 'func') # 使用getattr函数获取func的值
>>> func()
I am a test

>>> func = getattr(test, 'f')  # 使用对象没有的属性,则会出现异常
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Test' object has no attribute 'f'
>>>
  1. 提供默认写法

如果对象没有该属性可以提供一个默认值。

1
2
3
4
>>> func = getattr(test, 'f', None)
>>> print func
None
>>>

Interpreter 类的源代码

下面是 Interpreter 类的源代码,它继承自 NodeVisitor 类且实现了形式为 visit_NodeType 的不同方法,其中 NodeType 会被如 BinOp, Num 等类名替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Interpreter(NodeVisitor):
    def __init__(self, parser):
    self.parser = parser

    def visit_BinOp(self, node):
    if node.op.type == PLUS:
        return self.visit(node.left) + self.visit(node.right)
    elif node.op.type == MINUS:
        return self.visit(node.left) - self.visit(node.right)
    elif node.op.type == MUL:
        return self.visit(node.left) * self.visit(node.right)
    elif node.op.type == DIV:
        return self.visit(node.left) / self.visit(node.right)

    def visit_Num(self, node):
    return node.value

关于以上代码有两点值得在这里提一下:

第一,操作 AST 结点的访问器(也就是对AST数据的操作)的代码 和 AST 结点(即,AST的数据结构)本身 分离(解耦了)。

第二,在NodeVisitor的访问函数中,不像这样使用一个巨大的if语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def visit(node):
    node_type = type(node).__name__
    if node_type == 'BinOp':
        return self.visit_BinOp(node)
    elif node_type == 'Num':
        return self.visit_Num(node)
    elif ...
    # ...
#####或者##########
def visit(node):
    if isinstance(node, BinOp):
        return self.visit_BinOp(node)
    elif isinstance(node, Num):
        return self.visit_Num(node)
    elif ...

NodeVisitor 的 visit 方法非常通用,能根据传入的结点类型来调度适当的方法。正如前面提到的,为了利用这一点,我们的解释器继承了 NodeVisitor 类并实现了必要的方法。 因此:

花此时间研究一下这个方法(Python 的标准模块 ast 也使用了相同的机制来遍历结点), 因为我们将来会用很多新的 visit_NodeType 方法来扩展我们的解释器。

现在,让我们手工为表达式 2 * 7 + 3 建立一个 AST 并把它传递给解释器,通过对该表达式求值看看运行中的 visit 方法。下面是你从 Python shell 中尝试的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> from spi import Token, MUL, PLUS, INTEGER, Num, BinOp
>>>
>>> mul_token = Token(MUL, '*')
>>> plus_token = Token(PLUS, '+')
>>> mul_node = BinOp(
...     left=Num(Token(INTEGER, 2)),
...     op=mul_token,
...     right=Num(Token(INTEGER, 7))
... )
>>> add_node = BinOp(
...     left=mul_node,
...     op=plus_token,
...     right=Num(Token(INTEGER, 3))
... )
>>> from spi import Interpreter
>>> inter = Interpreter(None)
>>> inter.visit(add_node)
17

如你所见,我把表达式树的根结点传递给了 visit 方法,这一行为触发了树的遍历,遍历调用了 Interpreter 类正确的方法(visit_BinOpvisit_Num)并生成了结果。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
""" SPI - Simple Pascal Interpreter """

# Token types
#
# EOF (end-of-file) token is used to indicate that
# there is no more input left for lexical analysis
INTEGER, PLUS, MINUS, MUL, DIV, LPAREN, RPAREN, EOF = (
    'INTEGER', 'PLUS', 'MINUS', 'MUL', 'DIV', '(', ')', 'EOF'
)


class Token(object):
    def __init__(self, type, value):
        self.type = type
        self.value = value

    def __str__(self):
        """String representation of the class instance.

        Examples:
            Token(INTEGER, 3)
            Token(PLUS, '+')
            Token(MUL, '*')
        """
        return 'Token({type}, {value})'.format(
            type=self.type,
            value=repr(self.value)
        )

    def __repr__(self):
        return self.__str__()

###############################################################################
#                                                                             #
#  LEXER                                                                      #
#                                                                             #
###############################################################################

class Lexer(object):
    def __init__(self, text):
        # client string input, e.g. "4 + 2 * 3 - 6 / 2"
        self.text = text
        # self.pos is an index into self.text
        self.pos = 0
        self.current_char = self.text[self.pos]

    def error(self):
        raise Exception('Invalid character')

    def advance(self):
        """Advance the `pos` pointer and set the `current_char` variable."""
        self.pos += 1
        if self.pos > len(self.text) - 1:
            self.current_char = None  # Indicates end of input
        else:
            self.current_char = self.text[self.pos]

    def skip_whitespace(self):
        while self.current_char is not None and self.current_char.isspace():
            self.advance()

    def integer(self):
        """Return a (multidigit) integer consumed from the input."""
        result = ''
        while self.current_char is not None and self.current_char.isdigit():
            result += self.current_char
            self.advance()
        return int(result)

    def get_next_token(self):
        """Lexical analyzer (also known as scanner or tokenizer)

        This method is responsible for breaking a sentence
        apart into tokens. One token at a time.
        """
        while self.current_char is not None:

            if self.current_char.isspace():
                self.skip_whitespace()
                continue

            if self.current_char.isdigit():
                return Token(INTEGER, self.integer())

            if self.current_char == '+':
                self.advance()
                return Token(PLUS, '+')

            if self.current_char == '-':
                self.advance()
                return Token(MINUS, '-')

            if self.current_char == '*':
                self.advance()
                return Token(MUL, '*')

            if self.current_char == '/':
                self.advance()
                return Token(DIV, '/')

            if self.current_char == '(':
                self.advance()
                return Token(LPAREN, '(')

            if self.current_char == ')':
                self.advance()
                return Token(RPAREN, ')')

            self.error()

        return Token(EOF, None)


###############################################################################
#                                                                             #
#  PARSER                                                                     #
#                                                                             #
###############################################################################

class AST(object):
    pass   # 表示定义留空


class BinOp(AST):
    def __init__(self, left, op, right):
        self.left = left
        self.token = self.op = op
        self.right = right


class Num(AST):
    def __init__(self, token):
        self.token = token
        self.value = token.value


class Parser(object):
    def __init__(self, lexer):
        self.lexer = lexer
        # set current token to the first token taken from the input
        self.current_token = self.lexer.get_next_token()

    def error(self):
        raise Exception('Invalid syntax')

    def eat(self, token_type):
        # compare the current token type with the passed token
        # type and if they match then "eat" the current token
        # and assign the next token to the self.current_token,
        # otherwise raise an exception.
        if self.current_token.type == token_type:
            self.current_token = self.lexer.get_next_token()
        else:
            self.error()

    def factor(self):
        """factor : INTEGER | LPAREN expr RPAREN"""
        token = self.current_token
        if token.type == INTEGER:
            self.eat(INTEGER)
            return Num(token)
        elif token.type == LPAREN:
            self.eat(LPAREN)
            node = self.expr()
            self.eat(RPAREN)
            return node

    def term(self):
        """term : factor ((MUL | DIV) factor)*"""
        node = self.factor()

        while self.current_token.type in (MUL, DIV):
            token = self.current_token
            if token.type == MUL:
                self.eat(MUL)
            elif token.type == DIV:
                self.eat(DIV)

            node = BinOp(left=node, op=token, right=self.factor())

        return node

    def expr(self):
        """
        expr   : term ((PLUS | MINUS) term)*
        term   : factor ((MUL | DIV) factor)*
        factor : INTEGER | LPAREN expr RPAREN
        """
        node = self.term()

        while self.current_token.type in (PLUS, MINUS):
            token = self.current_token
            if token.type == PLUS:
                self.eat(PLUS)
            elif token.type == MINUS:
                self.eat(MINUS)

            node = BinOp(left=node, op=token, right=self.term())

        return node

    def parse(self):
        return self.expr()


###############################################################################
#                                                                             #
#  INTERPRETER                                                                #
#                                                                             #
###############################################################################

class NodeVisitor(object):
    def visit(self, node):
        method_name = 'visit_' + type(node).__name__
        visitor = getattr(self, method_name, self.generic_visit)
        return visitor(node)

    def generic_visit(self, node):
        raise Exception('No visit_{} method'.format(type(node).__name__))


class Interpreter(NodeVisitor):
    def __init__(self, parser):
        self.parser = parser

    def visit_BinOp(self, node):
        if node.op.type == PLUS:
            return self.visit(node.left) + self.visit(node.right)
        elif node.op.type == MINUS:
            return self.visit(node.left) - self.visit(node.right)
        elif node.op.type == MUL:
            return self.visit(node.left) * self.visit(node.right)
        elif node.op.type == DIV:
            return self.visit(node.left) / self.visit(node.right)

    def visit_Num(self, node):
        return node.value

    def interpret(self):
        tree = self.parser.parse()
        return self.visit(tree)


def main():
    while True:
        try:
            try:
                text = raw_input('spi> ')
            except NameError:  # Python3
                text = input('spi> ')
        except EOFError:
            break
        if not text:
            continue

        lexer = Lexer(text)
        parser = Parser(lexer)
        interpreter = Interpreter(parser)
        result = interpreter.interpret()
        print(result)


if __name__ == '__main__':
    main()

将以上代码保存到名为 spi.py 的文件中,或者直接从 GitHub 下载。自己试一试,确认 你的新的基于树的解释器可以正确地对算术表达式进行求值。

下面是某次运行过程:

1
2
3
4
5
6
7
$ python spi.py
spi> 7 + 3 * (10 / (12 / (3 + 1) - 1))
22
spi> 7 + 3 * (10 / (12 / (3 + 1) - 1)) / (2 + 3) - 5 - 3 + (8)
10
spi> 7 + (((3 + 2)))
12

小结

今天你学习了关于解析树和 AST,如何构建 AST 以及遍历表示输入的 AST 并解释执行。你还修改了 parser 和 interpreter 并将这两部分解开了。现在 lexer, parser 和 interpreter 之间的接口看起来像这样:

lsbasi_part7_pipeline

你可以把它读作“parser 从 lexer 中 得到 token 然后返回生成的 AST 给 Interpreter 进行遍历并解释执行所给输入”。

递归

这就是今天的所有内容,但在总结之前我还想简单地聊一聊递归下降 (recursive-descent) parser,即是仅仅给出它的定义。

定义就是:一个 递归下降parser 就 是一个自顶向下的 parser,它使用一组递归过程来处理输入。自顶向下反映了 parser 从 构建解析树的顶部结点开始逐渐构建更低的结点这一事实。

梳理

本节与part6相比主要添加了AST:

  1. Lexer与part6中的是一样的,还是将输入的text分析转换为token。(这也是词法分析器的功能)
  2. Parser相比part6,
    • 它添加了构造AST的内容:添加了三个类:
      • AST():是一个基类
      • BinOp(AST):继承于AST(),主要功能是实现二元操作符binary operator 。(这里只有四种:加、减、乘、除法)
      • Num(AST):继承于AST(),它主要是表示AST中的整数integer token(它将保存一个 INTEGER token 和该 token 的值)
    • 第二个变化就是实现语法解析的这三个函数termfactorexpr中的返回的不在是result变量了,而是返回一个结点node。
  3. Interpreter:通过词法分析、语法分析之后,开始解释语法分析之后的算式,计算出它的结果,用Interpreter来解释:
    • 增添了访问者模式:将 对数据的操作(对数据的操作在interpreter中完成:访问、解释) 和 数据结构(数据结构 在parser中的三个类中构建) 进行分离(解耦合)。使得操作集合可相对自由地演化。
      • 这里通过创建 NodeVisitor()类 实现访问者模式。实现了通过什么方式去访问生成的AST
      • 又通过Interpreter(继承于 NodeVisitor()类)来实现解释生成的AST

最后,再次提醒一下:之前的那几小结,我们都是把interpreter的代码和parser的代码混在一起,但这一小节我们把interpreter的代码和parser的代码分开了


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值