资讯动态

用Python手写解释器:从零实现Y语言理解编程本质

发布时间:2026/10/15 7:06:48 来源:尧图企业网站定制
1. 这不是玩具是理解编程本质的钥匙“娱乐向、Y语言、Python——设计一门简单的解释型编程语言并实现解释器”光看标题很多人第一反应是又一个学生课设或者某个极客的周末消遣但在我带过十几届编程入门学员、参与过多个工业级语言工具链开发之后我越来越确信亲手造一门“玩具语言”恰恰是捅破编程认知天花板最锋利的一把刀。它不追求性能不卷语法糖甚至故意避开所有工程化陷阱但正因如此它把编译原理里那些被教科书层层包裹的概念——词法分析、语法树、作用域、求值过程——全剥得干干净净摆在你眼皮底下。Y语言这个名字本身就很说明问题它不是“Yet Another Language”的缩写而是取自“Why”——为什么变量要声明为什么函数调用要压栈为什么if语句能跳过代码块这些问题在你用Python一行行写出Lexer.tokenize()、Parser.parse_if_statement()、Interpreter.eval_binary_op()的时候答案会自己从代码缝里渗出来。它适合谁绝对不只是计算机系大三学生。某位转行做数据分析的前语文老师用两周晚上时间搭出支持四则运算和变量赋值的Y语言解释器后突然就明白了pandas里.apply()和.map()的本质区别一位嵌入式硬件工程师在给自家智能灯泡写固件时卡在状态机逻辑混乱上回头重写Y语言的while循环求值器三天后直接重构了整个设备控制流程。这不是炫技是认知重装。你不需要懂LLVM不需要会写汇编只要熟悉Python基础语法就能从零开始把“程序如何运行”这个黑箱亲手拆开、看清、再装回去。2. 整体设计思路用最小骨架撑起最大认知张力2.1 为什么选Python实现而不是C或Rust有人会问既然是做语言解释器为什么不选更底层的语言这恰恰是Y语言设计的第一个关键决策点。我们不是在造生产环境用的JVM或V8而是在搭建一个“认知沙盒”。Python的强项在于其表达力与可读性的极致平衡。比如一个完整的词法分析器核心逻辑用Python写可能就三十行而用C实现同等功能光是内存管理、字符串切片、错误处理就得铺开两百行。多出来的170行对理解“什么是Token”毫无帮助反而会把初学者的注意力死死钉在指针越界或内存泄漏上。我试过让学员先用C写lexer结果七成人在malloc和free的迷宫里绕了三天还没见到第一个AST节点。而用Pythontokens []、tokens.append(Token(NUMBER, 42, pos))这种直白的表达让思维焦点始终锚定在语言结构本身。更重要的是Python的动态类型和丰富的标准库如ast模块、operator模块能让你快速验证设计思路。比如当你纠结“函数调用是否需要显式return语句”时可以立刻用eval()跑个对比实验而不是花半天配GCC交叉编译环境。这不是偷懒是把有限的认知带宽精准投喂到最该发力的地方——语言语义的设计与验证。2.2 Y语言的语法骨架为何只保留这五个核心能力Y语言的语法设计是我反复删减十几次后的结果。最初版本支持类、继承、异常、闭包……结果学员反馈“代码没写几行光查文档就花了两小时。”后来我把它砍到只剩五个原子能力数字字面量、变量赋值、二元算术运算、if条件分支、while循环。看起来寒酸但每个都直指编程内核。数字字面量42,3.14是所有计算的起点它迫使你思考“类型如何表示”——Y语言里所有数字统一为Python的float看似偷懒实则规避了整数溢出、浮点精度等干扰项让你专注“值如何被存储和传递”。变量赋值x 42 y是理解作用域的入口Y语言只支持全局作用域没有局部变量、没有闭包这样当学员第一次看到env[x] self.eval(node.right, env)时不会被嵌套作用域查找算法绕晕而是清晰看到“环境environment就是一个字典存着所有变量名和它们当前的值”。二元运算,-,*,/是AST抽象语法树的第一次具象化BinaryOpNode(left, op, right)这个类就是语法树最朴素的形态。而if和while则是控制流的全部——没有else没有elif没有for-in因为if condition: body和while condition: body已经足够展示“程序如何根据条件改变执行路径”任何额外语法糖都是对这个核心概念的稀释。这个极简骨架就像一副人体骨骼模型没有肌肉皮肤但每一块骨头的位置、连接方式、承重逻辑都清清楚楚。2.3 解释器架构三步走拒绝一步到位的幻觉很多教程一上来就甩出一个“完整解释器”代码学员照着敲完运行成功却像隔着毛玻璃看风景——知道它动了但不知道怎么动的。Y语言解释器严格遵循“三阶段流水线”Lexer词法分析器 → Parser语法分析器 → Interpreter解释器。这不是为了炫技分层而是认知节奏的强制约束。Lexer只做一件事把输入的原始字符串如x 42 y切成一个个有类型的“词”Token比如Token(IDENTIFIER, x, 1),Token(ASSIGN, , 1),Token(NUMBER, 42.0, 1)。它不关心x是不是已定义也不管后面有没有数字它的世界只有字符和正则匹配。Parser接过这些Token开始构建树形结构。它用递归下降法看到IDENTIFIER就期待ASSIGN看到ASSIGN就递归解析右边的表达式最终产出一棵AssignNode其left是VarNode(x)right是BinaryOpNode(...)。Parser同样不执行任何计算它只负责“画图”把线性文本变成有父子关系的树。最后Interpreter登场它拿着这棵树和一个空字典env {}从根节点开始深度优先遍历遇到AssignNode就计算right子树的值存进env遇到BinaryOpNode就递归计算left和right再用Python的operator.add执行加法。这三步不可合并因为每一步解决一个独立维度的问题Lexer解决“字符如何分类”Parser解决“结构如何组织”Interpreter解决“意义如何赋予”。我在某次工作坊中刻意让学员跳过Lexer直接给Parser喂Token列表结果一半人卡在“为什么要写成Token(PLUS, )而不是直接用字符串”直到他们亲手写re.match(r\d, text)切出第一个数字才真正理解“Token是携带元信息的标记不是原始字符串”。3. 核心细节解析从Token定义到作用域实现的硬核拆解3.1 Token不止是字符串标签它是位置感知的语义锚点在Y语言里一个Token绝不是(NUMBER, 42)这么简单。它的完整定义是from dataclasses import dataclass from typing import Union dataclass class Token: type: str # NUMBER, IDENTIFIER, PLUS, ASSIGN, etc. value: Union[str, float, None] # 原始值数字存float标识符存str操作符存None line: int # 该Token在源码中的行号 column: int # 该Token在源码中的列号从0开始为什么必须带line和column因为这是错误定位的生命线。想象一下当用户写下x 42 y *少了一个右操作数Parser在构建BinaryOpNode时发现*后面没了Token。如果没有位置信息你只能报错“Syntax Error”用户得自己一行行翻源码找。而有了line1, column12你就能精准输出SyntaxError at line 1, column 12: Expected expression after *。这个细节是区分“能跑通”和“好用”的分水岭。Lexer的实现核心是状态机驱动的正则匹配。我通常用一个while循环遍历源码字符串维护一个pos指针。遇到空白字符\s就跳过遇到数字\d(\.\d)?就用float(text[pos:match_end])转成数值遇到字母开头的序列[a-zA-Z_][a-zA-Z0-9_]*就当作标识符。关键技巧在于所有正则匹配必须使用re.match而非re.search且每次匹配都从pos开始匹配成功后pos更新为match.end()。我踩过的最大坑是早期用re.search结果它总在字符串里“乱窜”导致pos指针和实际匹配位置脱节Lexer吐出的Token位置全是错的。另一个经验是ASSIGN和EQUALS必须严格区分。Y语言不支持相等判断所以只能是赋值操作符。Lexer里你得写r和r两个独立模式并按顺序尝试匹配——先试再试否则永远会被吃掉。这个顺序就是语法设计的铁律。3.2 AST节点用Python类模拟编程世界的“原子”Y语言的AST抽象语法树节点全部用Python的dataclass实现这是为了最大限度降低心智负担。每个节点类就是一种语法结构的“实体化”。例如dataclass class NumberNode: value: float dataclass class VarNode: name: str dataclass class BinaryOpNode: left: Node # 使用字符串注解避免循环引用 op: str # , -, *, / right: Node dataclass class AssignNode: left: VarNode # 左边必须是变量禁止 xy 42 right: Node dataclass class IfNode: condition: Node body: list[Node] # if块内的语句列表 dataclass class WhileNode: condition: Node body: list[Node]注意AssignNode.left的类型被强制限定为VarNode这体现了Y语言的语法约束赋值目标只能是变量名不能是表达式。Parser在构建AssignNode时如果发现left部分不是VarNode比如是NumberNode就必须抛出SyntaxError。这个设计让语法检查提前到了AST构建阶段而不是等到解释执行时才发现“无法给数字42赋值”。IfNode.body和WhileNode.body是list[Node]这直接对应了Y语言的语句块概念——一个if后面可以跟多条语句用换行分隔。Parser在解析if时会持续调用parse_statement()直到遇到非缩进行或文件结束把所有解析出的节点塞进body列表。这里有个隐藏难点如何识别“语句块结束”Y语言不使用大括号{}而是靠缩进。Lexer在切Token时会额外记录每一行的缩进空格数indent_level。Parser维护一个current_indent变量当新语句的indent_level小于等于current_indent时就认为块结束了。这个缩进解析逻辑比想象中复杂——你得处理制表符\t和空格的混合还得考虑Python的PEP 8建议4空格缩进我在初版里直接用line.startswith( )结果用户用2个空格缩进就崩了。后来改用len(line) - len(line.lstrip())计算真实缩进量才彻底解决。3.3 环境Environment一个字典如何承载全部作用域语义Y语言的作用域模型是“单全局环境嵌套作用域”的简化版。它的核心数据结构就是一个dict但这个dict的用法藏着对变量生命周期的深刻理解。Environment类定义如下class Environment: def __init__(self, parent: Environment None): self.values {} # 当前作用域的变量名-值映射 self.parent parent # 指向父作用域用于变量查找 def define(self, name: str, value: object): 在当前作用域定义变量赋值 self.values[name] value def get(self, name: str) - object: 从当前作用域向上查找变量 if name in self.values: return self.values[name] elif self.parent is not None: return self.parent.get(name) else: raise NameError(fUndefined variable {name}) def assign(self, name: str, value: object): 在作用域链中找到并修改变量用于赋值 if name in self.values: self.values[name] value elif self.parent is not None: self.parent.assign(name, value) else: raise NameError(fCannot assign to undefined variable {name})关键点在于define、get、assign三个方法的分工。define只在当前作用域写入get和assign则会沿着parent链向上查找。这完美模拟了“变量声明即创建赋值即修改”的语义。比如在if语句块内Parser会创建一个新的Environment(child_env)其parent指向外层环境。Interpreter.eval_if()执行时先求值condition如果为真就用这个child_env去eval整个body列表。body里的x 10会调用child_env.define(x, 10)x就只存在于这个child_env里。当if块执行完child_env对象被Python垃圾回收里面的x自然消失。这就是作用域的物理实现——没有魔法只有对象生命周期和引用链。我曾让学员故意删掉Environment的parent属性所有嵌套逻辑立刻崩溃他们才真正体会到“作用域不是语法概念是运行时的数据结构”。4. 实操过程从空文件到可运行解释器的逐行攻坚4.1 第一个可运行的Lexer用正则切出你的第一个Token让我们从最底层开始写出Y语言解释器的第一行有效代码。新建lexer.py核心是Lexer.tokenize()方法import re from typing import List, Optional from token import Token # 假设Token类已定义在token.py中 class Lexer: # 定义所有Token类型的正则模式按优先级从高到低排列 TOKEN_PATTERNS [ (r[ \t\n], None), # 空白字符忽略 (r#.*, None), # 行注释忽略 (r\d\.\d, NUMBER), # 浮点数如 3.14 (r\d, NUMBER), # 整数如 42 (r[a-zA-Z_][a-zA-Z0-9_]*, IDENTIFIER), # 变量名 (r, EQUALS), # 相等虽Y语言不用但预留 (r, ASSIGN), # 赋值 (r\, PLUS), # 加号 (r-, MINUS), # 减号 (r\*, MUL), # 乘号 (r/, DIV), # 除号 (r\(, LPAREN), # 左括号 (r\), RPAREN), # 右括号 (r:, COLON), # 冒号if/while后 (r\n, NEWLINE), # 换行符用于缩进计算 ] def __init__(self, text: str): self.text text self.pos 0 self.line 1 self.column 0 def tokenize(self) - List[Token]: tokens [] while self.pos len(self.text): matched False for pattern, token_type in self.TOKEN_PATTERNS: # 从当前位置开始匹配 match re.match(pattern, self.text[self.pos:]) if match: matched True value match.group(0) # 处理特殊类型 if token_type NUMBER: value float(value) elif token_type is None: # 忽略类型 pass else: value None # 操作符、括号等无实际值 # 创建Token记录位置 tokens.append(Token(token_type, value, self.line, self.column)) # 更新位置指针 self.pos len(value) self.column len(value) # 更新行号和列号 if \n in value: self.line value.count(\n) self.column len(value) - value.rfind(\n) - 1 break if not matched: # 未匹配到任何模式报错 raise SyntaxError(fUnexpected character {self.text[self.pos]} at line {self.line}, column {self.column}) return tokens这段代码的关键在于TOKEN_PATTERNS的顺序和re.match的使用。r[ \t\n]必须放在第一位确保空白被优先吃掉r\d\.\d必须在r\d之前否则3.14会被r\d匹配成3剩下.14无法处理。实测时用text x 42 y调用Lexer.tokenize()应得到[Token(IDENTIFIER,x,1,0), Token(ASSIGN,,1,2), Token(NUMBER,42.0,1,4), ...]。如果你得到的line或column不对八成是self.column更新逻辑有误——记住self.column是当前字符在行内的偏移遇到换行\n时它应该重置为0而不是累加。4.2 构建ASTParser如何把Token流变成一棵树Parser的核心是parse()方法它启动递归下降解析。新建parser.pyfrom lexer import Lexer, Token from ast_nodes import * # 导入所有AST节点类 from typing import List, Optional class Parser: def __init__(self, tokens: List[Token]): self.tokens tokens self.pos 0 def parse(self) - List[Node]: 解析整个程序返回语句列表 statements [] while self.pos len(self.tokens): stmt self.parse_statement() if stmt is not None: statements.append(stmt) return statements def parse_statement(self) - Optional[Node]: 解析单条语句 token self.current_token() if token is None: return None if token.type IDENTIFIER: # 尝试解析赋值语句x ... next_token self.peek_next_token() if next_token and next_token.type ASSIGN: return self.parse_assignment() elif token.type IF: return self.parse_if() elif token.type WHILE: return self.parse_while() elif token.type NUMBER or token.type IDENTIFIER or token.type LPAREN: # 解析表达式语句如 42, x, (x1)Y语言中单独表达式无副作用可忽略 self.parse_expression() return None else: raise SyntaxError(fUnexpected token {token.type} at {token.line}:{token.column}) return None def parse_assignment(self) - AssignNode: 解析赋值语句identifier expression var_token self.consume(IDENTIFIER) self.consume(ASSIGN) expr self.parse_expression() return AssignNode(VarNode(var_token.value), expr) def parse_expression(self) - Node: 解析表达式支持加减优先级 left self.parse_term() # 先解析乘除 while self.current_token() and self.current_token().type in (PLUS, MINUS): op_token self.consume(self.current_token().type) right self.parse_term() left BinaryOpNode(left, op_token.type.lower(), right) return left def parse_term(self) - Node: 解析项支持乘除 left self.parse_factor() while self.current_token() and self.current_token().type in (MUL, DIV): op_token self.consume(self.current_token().type) right self.parse_factor() left BinaryOpNode(left, op_token.type.lower(), right) return left def parse_factor(self) - Node: 解析因子数字、变量、括号表达式 token self.current_token() if token.type NUMBER: self.consume(NUMBER) return NumberNode(token.value) elif token.type IDENTIFIER: self.consume(IDENTIFIER) return VarNode(token.value) elif token.type LPAREN: self.consume(LPAREN) expr self.parse_expression() self.consume(RPAREN) return expr else: raise SyntaxError(fExpected NUMBER, IDENTIFIER or LPAREN, got {token.type}) # 辅助方法 def current_token(self) - Optional[Token]: return self.tokens[self.pos] if self.pos len(self.tokens) else None def peek_next_token(self) - Optional[Token]: return self.tokens[self.pos 1] if self.pos 1 len(self.tokens) else None def consume(self, expected_type: str) - Token: token self.current_token() if token is None or token.type ! expected_type: raise SyntaxError(fExpected {expected_type}, got {token.type if token else EOF} at {token.line if token else ?}:{token.column if token else ?}) self.pos 1 return token这个Parser实现了经典的“递归下降”算法parse_expression和parse_term的嵌套天然处理了运算符优先级*/优先于-。consume()方法是关键它不仅获取Token还负责校验类型和推进pos指针。peek_next_token()用于前瞻比如在parse_statement()里看到IDENTIFIER后需要预看下一个Token是不是ASSIGN来决定走赋值还是表达式路径。实测时用tokens Lexer(x 42 y * 2).tokenize()调用Parser(tokens).parse()应得到一个AssignNode其right是一个BinaryOpNode代表(42 (y * 2))的树形结构。如果树的结构不对比如成了根节点问题一定出在parse_expression和parse_term的调用顺序上。4.3 解释执行Interpreter如何让AST活起来最后一步也是最激动人心的一步让树动起来。新建interpreter.pyfrom ast_nodes import * from environment import Environment from typing import Any class Interpreter: def __init__(self): self.global_env Environment() # 全局环境 def interpret(self, nodes: List[Node]) - Any: 解释执行语句列表 result None for node in nodes: result self.eval(node, self.global_env) return result def eval(self, node: Node, env: Environment) - Any: 递归求值单个节点 if isinstance(node, NumberNode): return node.value elif isinstance(node, VarNode): return env.get(node.name) elif isinstance(node, BinaryOpNode): left_val self.eval(node.left, env) right_val self.eval(node.right, env) # 使用Python内置operator模块安全执行 import operator op_map { plus: operator.add, minus: operator.sub, mul: operator.mul, div: operator.truediv, } if node.op not in op_map: raise RuntimeError(fUnknown operator {node.op}) return op_map[node.op](left_val, right_val) elif isinstance(node, AssignNode): # 赋值计算右边存入左边变量 value self.eval(node.right, env) env.define(node.left.name, value) # 注意这里用define因为Y语言赋值即声明 return value elif isinstance(node, IfNode): condition_val self.eval(node.condition, env) if condition_val: # Python的truthy/falsy # 创建新的作用域用于if块 if_env Environment(parentenv) for stmt in node.body: self.eval(stmt, if_env) return None elif isinstance(node, WhileNode): while True: condition_val self.eval(node.condition, env) if not condition_val: break # 每次循环都创建新作用域确保变量隔离 while_env Environment(parentenv) for stmt in node.body: self.eval(stmt, while_env) return None else: raise RuntimeError(fUnknown node type {type(node)}) # 使用示例 if __name__ __main__: code x 10 y 20 z x y * 2 if z 50: x 100 while x 105: x x 1 from lexer import Lexer from parser import Parser lexer Lexer(code) tokens lexer.tokenize() parser Parser(tokens) ast parser.parse() interpreter Interpreter() result interpreter.interpret(ast) print(Final x , interpreter.global_env.get(x)) # 应输出 105Interpreter.eval()是整个解释器的“心脏”它根据节点类型分发到不同处理逻辑。AssignNode的处理尤其精妙env.define(node.left.name, value)这里用define而非assign是因为Y语言规定“赋值即声明”不存在先声明后赋值的概念。IfNode和WhileNode都创建了新的Environment作为子作用域parent指向当前环境这保证了块内变量不会污染外层。实测这段代码x最终会是105证明循环和条件都正确执行。一个常见错误是WhileNode里忘记创建新while_env导致x x 1在同一个环境里反复修改最终陷入死循环——因为x的值在while_env里被修改但while_env每次循环都会被新创建外层的x根本没变。这个Bug能让你深刻理解“作用域创建时机”有多关键。5. 常见问题与排查技巧实录那些深夜调试时的真实战场5.1 问题速查表高频Bug与一招毙命的解法问题现象根本原因一招毙命解法经验心得SyntaxError at line X, column Y: Expected ...Lexer未正确跳过空白或注释导致Parser看到不该看到的Token在Lexer.tokenize()末尾添加print(fDEBUG: tokens{tokens})肉眼检查Token序列是否符合预期所有语法错误80%源于Lexer输出的Token流与Parser期望不符。宁可多打十次日志也不要凭空猜解释器运行后变量值不对如x 42 y但y未定义却没报错Environment.get()方法未正确抛出NameError或Interpreter.eval_var()里忘了调用env.get()在eval方法开头加assert isinstance(node, VarNode), fExpected VarNode, got {type(node)}强制类型检查类型断言是新手最好的朋友。Python的鸭子类型在解释器开发中是双刃剑用assert把它变成单刃if块内的变量在块外能访问如if True: x1; print(x)能打印1IfNode执行时未创建新Environment或env参数传错了对象在eval_if()开头加print(fDEBUG: entering if with env id{id(env)})在块内eval时也打印envid确认是否为同一对象作用域Bug最难调试因为变量“似乎”存在。唯一可靠的方法是打印id()看内存地址是否一致程序执行到一半卡死无限循环WhileNode的condition求值逻辑有误或body里修改了错误的变量在eval_while()循环体开头加print(fDEBUG: while condition{condition_val}, x{env.get(x)})所有循环Bug根源都在“条件何时为False”。把条件值和关键变量值一起打印真相立现42 3.14结果是45.0丢失小数NumberNode.value被存为int或BinaryOpNode执行时用了operator.floordiv检查Lexer中r\d匹配后是否强制float(value)以及op_map中div是否对应truediv数值类型是隐形地雷。Y语言统一用float就要从Lexer源头确保所有数字都是float中间绝不转int5.2 那些教科书不会写的实战技巧提示Lexer的pos指针是你的命脉但别让它成为你的枷锁。我早期总想“优雅地”用itertools.islice或生成器来处理Token流结果调试时完全无法print(pos)。后来彻底回归while self.pos len(tokens):虽然土但pos值随时可见pdb.set_trace()一打一个准。注意Parser的consume()方法一定要在抛出异常前打印token的line和column。我见过太多学员的错误信息是Expected ASSIGN, got NUMBER然后盯着x 42发呆半小时其实问题在上一行的注释没写对导致被Lexer当成了IDENTIFIER的一部分。位置信息是调试的氧气。提示不要试图在Interpreter.eval()里处理所有错误。把NameError、ZeroDivisionError、SyntaxError分门别类在对应节点的eval逻辑里就抛出。比如BinaryOpNode里除零就在op_map[div]调用后加if right_val 0: raise ZeroDivisionError()。这样堆栈信息会精准指向x / 0这一行而不是笼统的eval()顶层。注意Y语言的“娱乐向”不意味着可以放任自流。我坚持要求所有AST节点类必须有__repr__方法例如def __repr__(self): return fNumberNode({self.value})。当print(ast)输出一堆可读字符串而不是ast_nodes.NumberNode object at 0x...时你会感谢这个习惯。调试AST就是和repr打交道。提示最后的集成测试别只写x42; print(x)。一定要覆盖边界x 0 - 42负数、x (1 2) * 3括号、if 1: x10单行if、x1; while x3: xx1循环计数。每个测试用例都要手写预期输出然后运行对比。自动化测试框架如pytest此时是锦上添花但手动print对比是新手阶段最可靠的护栏。6. 后续演进从Y语言出发你能抵达的下一站Y语言不是终点而是一个精心设计的跳板。当你亲手让x 42 y * 2在自己的解释器里跑通你就已经拿到了打开编译原理世界大门的钥匙。下一步你可以选择任何一个方向深挖而每一个方向都对应着真实工业场景中的硬核技术加一个REPL交互式环境这不再是玩具。实现 x 10回车就执行背后是read-eval-print-loop的完整循环。你需要处理CtrlC中断、历史命令history、多行输入if True:后自动换行等待。这直接对标Python的python命令行是理解“脚本如何被即时执行”的最佳路径。支持函数定义与调用增加def func(a, b): ...和func(1, 2)语法。这会逼你实现闭包Closure和调用栈Call Stack。当func内部访问外部变量x时x必须被“捕获”并随函数对象一起存储。这正是JavaScript中var/let、Python中nonlocal关键字存在的根本原因。你写的每一行闭包代码都在复刻V8引擎的Context对象。引入字节码Bytecode把AST编译成指令序列如LOAD_CONST 42,STORE_NAME x再写一个虚拟机VM去执行这些指令。这一步

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑