class TokenType(Enum):
ILLEGAL = "ILLEGAL"
EOF = "EOF" # end of file
NEWLINE = "NEWLINE" # \n, \r, \r\n
WHITESPACE = "WHITESPACE" # space, tab
PLUS = "PLUS" # +
MINUS = "MINUS" # -
ASTERISK = "ASTERISK" # *
SLASH = "SLASH" # /
CARET = "CARET" # ^
LPAREN = "LPAREN" # (
RPAREN = "RPAREN" # )
INT = "INT" # integer
FLOAT = "FLOAT" # float number
class Token:
def __init__(
self, token_type: TokenType, literal: str, line: int, column: int
) -> None:
self.type: TokenType = token_type
self.literal: str = literal
self.line: int = line
self.column: int = column
def __repr__(self) -> str:
return f"Token(type={self.type}, literal={repr(self.literal)}, line={self.line}, column={self.column})"
def __str__(self):
return self.literal
def __eq__(self, other: object) -> bool:
if not isinstance(other, Token):
return False
return (
self.type == other.type
and self.literal == other.literal
and self.line == other.line
and self.column == other.column
)词法分析:符号
约 1562 字大约 5 分钟
2026-02-17
对于词法分析器来说,输入是一个字符串,输出是一个符号流。于是我们先定义输出数据。下面是符号的定义。
TokenType是一个枚举类型,用于表示符号的类型。其中 L9-L17 是计算器中全部需要的符号类型。此外,L5-L8 是一些辅助符号,后续用于语法分析,可以表示语句的开始结束,分隔符等等。
Token是一个数据类,用于表示符号。它包含了符号的类型和值。以及行号和列号,用于错误提示。这里我们需要关注用两个下划线开头的方法。它们被称为 Python 的魔术方法 magic method。
__init__()方法用于初始化对象__eq__()方法用于判断两个对象是否相等,用于比较符号是否相同,当我们运行var1 == var2时,会调用这个方法。__repr__()方法用于表示对象的字符串形式,用于调试和打印__str__()方法用于表示对象的字符串形式,用于打印
这里比较一下__repr__()和__str__()方法。
| 特性 | __repr__() | __str__() |
|---|---|---|
| 目标 | 面向开发者,用于调试和开发 | 面向终端用户,提供友好的可读性 |
| 输出格式 | 详细、无歧义,最好能重建对象 | 简洁、直观、易于理解 |
| 调用场景 | 交互式解释器、repr() 函数、调试、日志、容器内元素显示 | print() 函数、str() 函数、格式化字符串 |
根据比较的结果,我们可以看到__repr__()方法返回的字符串包含了符号的类型和值,以及行号和列号。而__str__()方法返回的字符串只包含了符号的值。这符合我们的预期。
L30 中的repr(self.literal)非常重要。对于普通的字符,它返回的字符串是一个单引号括起来的字符串,例如'a'。而对于特殊字符,例如换行符\n,它返回的字符串是一个双引号括起来的字符串,例如'\n'。这在调试中非常有用,因为我们可以直接在调试器中查看符号的值,特别是像回车和空格这样的非可见字符。
下面我们引入工程上比较重要的内容:测试。当我们修改代码的时候,需要测试代码是否按照预期工作。这可以通过编写测试用例来实现。测试用例是一组输入和预期输出的组合,用于验证代码的正确性。一方面,测试用例可以帮助我们发现代码中的错误和异常情况。另一方面,测试用例也可以帮助我们确保代码的质量和稳定性。
在这个项目中,我们使用 PyTest 来编写测试用例。PyTest 是一个功能强大的测试框架,它可以帮助我们快速编写和运行测试用例。我们可以使用 PyTest 来测试词法分析器的输出是否符合预期。我们已经创建了tests/test_token.py文件,用于测试词法分析器的输出。下面是 PyTest 在这个项目中可能需要用到的命令。
$ pytest # find all test files in tests/ directory
$ pytest tests/test_token.py # specify a test file
$ pytest --collect-only -q # list all test cases
$ pytest "tests/test_token.py::test_token[token5-Token(type=TokenType.MINUS, literal='-', line=2, column=3)--]"我们创建了 13 个符号类型。为了测试这些符号类型,我们编写了 13 个测试用例,覆盖全部类型。为了确保每个符号类型都被正确识别,我们测试了每个符号类型的__repr__()和__str__()方法的输出,以查看结果是否符合预期。
我们展示测试代码。
test_token()是测试函数,接受 3 个参数:token、expected_expr、expected_str。在函数中我们用断言语句assert token == expected_expr和assert str(token) == expected_str来验证符号是否符合预期。assert 语句用于检查表达式是否为 True,如果为 False 则会抛出 AssertionError 异常。AssertionError 可以包含字符串参数,在这里我们使用了 f-string,把期望的值和错误的值都放进去,这样可以方便进一步调试。测试函数和普通的函数没有区别,后续这种测试的方法会推广到项目的其他模块。
@pytest.mark.parametrize()是一个装饰器 decorator。在 Python 中,装饰器用于修改函数或类的行为。它可以帮助我们减少重复的代码,提高代码的可读性和可维护性。在 PyTest 中用于参数化测试用例,提高测试效率。它实际上有两个参数,第一个参数是一个字符串,用于指定测试用例的参数名,这和test_token()的参数是一一对应的。第二个参数是一个可迭代对象,用于指定测试用例的参数值。我们只用了一个列表,每个元素是一个元组。PyTest 会自动迭代,将每个元组中的参数代入到test_token()中去执行。
特别需要注意的是,L14-L23 定义了两个转义字符的测试用例。这是因为在 Python 中,转义字符是一个特殊的字符,用于表示一些不可见的字符,例如换行符\n、制表符\t等等。如果我们直接在字符串中使用这些字符,会导致语法错误。为了避免这个问题,我们需要使用转义字符来表示这些字符。这也是我们之前使用repr(self.literal)的原因。
@pytest.mark.parametrize(
"token, expected_repr, expected_str",
[
(
Token(TokenType.ILLEGAL, "abc", 1, 2),
"Token(type=TokenType.ILLEGAL, literal='abc', line=1, column=2)",
"abc",
),
(
Token(TokenType.EOF, "\0", 3, 4),
r"Token(type=TokenType.EOF, literal='\x00', line=3, column=4)",
"\0",
),
(
Token(TokenType.NEWLINE, "\n", 5, 6),
r"Token(type=TokenType.NEWLINE, literal='\n', line=5, column=6)",
"\n",
),
(
Token(TokenType.WHITESPACE, " ", 7, 8),
"Token(type=TokenType.WHITESPACE, literal=' ', line=7, column=8)",
" ",
),
(
Token(TokenType.PLUS, "+", 9, 1),
"Token(type=TokenType.PLUS, literal='+', line=9, column=1)",
"+",
),
(
Token(TokenType.MINUS, "-", 2, 3),
"Token(type=TokenType.MINUS, literal='-', line=2, column=3)",
"-",
),
(
Token(TokenType.ASTERISK, "*", 4, 5),
"Token(type=TokenType.ASTERISK, literal='*', line=4, column=5)",
"*",
),
(
Token(TokenType.SLASH, "/", 6, 7),
"Token(type=TokenType.SLASH, literal='/', line=6, column=7)",
"/",
),
(
Token(TokenType.CARET, "^", 8, 9),
"Token(type=TokenType.CARET, literal='^', line=8, column=9)",
"^",
),
(
Token(TokenType.LPAREN, "(", 1, 2),
"Token(type=TokenType.LPAREN, literal='(', line=1, column=2)",
"(",
),
(
Token(TokenType.RPAREN, ")", 3, 4),
"Token(type=TokenType.RPAREN, literal=')', line=3, column=4)",
")",
),
(
Token(TokenType.INT, "123", 5, 6),
"Token(type=TokenType.INT, literal='123', line=5, column=6)",
"123",
),
(
Token(TokenType.FLOAT, "4.56", 7, 8),
"Token(type=TokenType.FLOAT, literal='4.56', line=7, column=8)",
"4.56",
),
],
)
def test_token(
token: Token,
expected_repr: str,
expected_str: str,
) -> None:
assert (
repr(token) == expected_repr
), f"repr: expected='{expected_repr}', actual='{repr(token)}'"
assert (
str(token) == expected_str
), f"str: expected='{expected_str}', actual='{token}'"后续的章节操作性非常强,代码量也比较大。我们会在每个章节中展示代码,但是如果只是拷贝代码,只能获得粗浅的理解。因为这里展示的都是已经被验证过可以运行的代码。我们需要自己动手写代码,调试出现的错误,才能有更多收获。
如果你最后调通了代码,那么把改动提交到 Git 仓库。
$ git add .
$ git commit -m "lexer: token"