InspiredPython 中文系列教程(二)
用基本的 Python 解决 Wordle 难题
Author Mickey Petersen
你听说过 Wordle 吗?这是一个看似简单的字谜。你被要求猜一猜今天的单词,它是一个由五个字母组成的英语单词。如果你猜错了,你会得到一些提示:如果你猜对了单词中某个字母的位置,这个字母就是绿色的;黄色字母,如果该字母出现在单词中,但不在那个位置;如果这个字母不在单词里,那就是灰色。
看似简单,但相当具有挑战性!以下是你如何用 Python 集合、列表理解、一点点运气来编写一个 Wordle 解算器!
挑战
每天 Wordle 都会产生一个新的挑战词,我们必须猜出来。由于我们只有六次猜测机会——该网站使用 cookies 来跟踪您的进展——我们必须谨慎选择!
从表面上看,有许多我们可以利用的线索:
The Python Wordle Solver in action.
-
这个单词正好有五个字母长。
-
它必须是英语,只有字母-没有标点符号,数字或其他符号。
-
猜测产生线索:
-
如果字符和在单词中的位置正确,则为绿色字母。
-
一个黄色的字母如果字符是在单词中表示,但是我们选错了位置。
-
如果角色在这个世界上根本不是而是则为灰色字母。
-
-
单词的数量是有限的,什么是有效单词仅限于 Wordle 使用的词典。
因为我不想尝试提取和 Wordle 一样的字典(那太容易了),所以我将使用一个免费的字典,它在/usr/share/dict/american-english中随 Linux 一起提供。字典是一个文本文件,每行一个单词。
有了这些规则和观察结果,我们就可以开始编写 Wordle 求解器的算法了。
加载和生成单词
首先,我们需要字典——如果你喜欢,可以随意使用你自己选择的一本。
接下来,我们需要对游戏规则进行编码:
import string DICT = "/usr/share/dict/american-english" ALLOWABLE_CHARACTERS = set(string.ascii_letters) ALLOWED_ATTEMPTS = 6 WORD_LENGTH = 5
我们可以尝试六次。单词长度为五,我们可以使用所有可用的字母字符。
我正在将允许的字符转换成 Python set(),这样我就可以使用成员检查集合中的许多特性——稍后会详细介绍。
由此,我可以生成一组符合规则的单词:
from pathlib import Path WORDS = { word.lower() for word in Path(DICT).read_text().splitlines() if len(word) == WORD_LENGTH and set(word) < ALLOWABLE_CHARACTERS }
这里我使用一个集合理解来生成一组合法的单词。我使用优秀的Path类直接从文件中读取。如果你不熟悉 Path,我 推荐你学习一下 Path ,因为它是一个优秀的特性。
但是正如你从理解中看到的,我正在过滤字典中的单词,所以只有那些长度合适的单词和,即单词中的字符集是ALLOWABLE_CHARACTERS的子集。换句话说,只选择存在于允许字符集中的词典单词。
英语字母频率分析
英语的特点是单词中字母分布不均。例如,字母E比X使用得更频繁。因此,如果我们可以用最常见的字母生成单词,我们就更有可能让 Wordle 匹配单词中的部分或全部字符。因此,我们的制胜策略是为我们的 Wordle 求解器提出一个算法,生成英语中最常用的字母。
幸运的是,我们有一本英语词典!
from collections import Counter from itertools import chain LETTER_COUNTER = Counter(chain.from_iterable(WORDS))
Counter类是一个有用的发明。这是一本经过修改的记数字典。当您向它提供值时,它将这些值作为键进行跟踪,并将出现的次数存储为该键的值。对我们来说非常有用,因为我们想要字母的频率。
为此,我使用了itertools模块中一个鲜为人知的函数chain。chain有一个相当隐蔽的方法叫做from_iterable,它接受一个单独的可迭代对象,并将其作为一个长的可迭代对象链来计算:
我认为一个例子最能说明这一点:
>>> list(chain.from_iterable(["inspired", "python"])) ['i', 'n', 's', 'p', 'i', 'r', 'e', 'd', 'p', 'y', 't', 'h', 'o', 'n']
因为字符串也是可迭代的,并且因为WORDS是一组字符串(可迭代的),我们分割了一个集合(或列表,等等)。)转化成他们的构成人物。这是字符串的一个有用的属性。您可以通过类似于set的东西来获取单词中的独特字符:
>>> set("hello") {'e', 'h', 'l', 'o'}
Sets are modelled on their mathematical cousins of the same name
这意味着集合只能保存唯一的值——不能重复——并且它们是无序的。这就是为什么字符集与字符串的顺序不同。
集合拥有许多有用的特性,比如测试一个集合是否完全包含在另一个集合(子集)中;得到两个集合重叠的元素(交集);合并两个集合(联合);诸如此类。
我们已经数过字母了,看起来相当不错:
>>> LETTER_COUNTER Counter({'h': 828, 'o': 1888, 'n': 1484, 'e': 3106, 's': 2954, 'v': 338, # ... etc ... })
但这只能给出字符的绝对数量。那么,更好的办法是把它分成占总收入的百分比。幸运的是,Counter类有一个方便的total方法,可以给出所有字母出现的总数。
把它变成频率表很容易:
LETTER_FREQUENCY = { character: value / LETTER_COUNTER.total() for character, value in LETTER_COUNTER.items() }
Python 3.10 增加了Counter.total()方法,所以如果你使用的是旧版本的 Python,你可以用做同样事情的sum(LETTER_COUNTER.values())代替它。
这里我使用一个字典理解来枚举LETTER_COUNTER的每个键和值(这是一个修改过的字典)并将每个值除以总计数:
>>> LETTER_FREQUENCY {'h': 0.02804403048264183, 'o': 0.06394580863674852, 'n': 0.050262489415749366, 'e': 0.10519898391193903, 's': 0.10005080440304827, # ... etc ... }
现在我们对字典中被认为是有效单词的子集的字母频率有了一个完美的统计。注意,我不是针对整个词典这样做的——只是我们认为合法的单词部分。这不太可能对排名产生太大影响,但这最终是我们所依据的一套词汇。
现在我们需要一种衡量每个单词的方法,这样我们就可以提出最可能的候选词。因此,我们需要使用字母频率表,并制作一个单词评分函数,对单词中字母的“常见”程度进行评分:
def calculate_word_commonality(word): score = 0.0 for char in word: score += LETTER_FREQUENCY[char] return score / (WORD_LENGTH - len(set(word)) + 1)
我再次利用了这样一个事实,即通过迭代单词中的每个字符,字符串是可迭代的。然后我得到每个单词的频率,并把它加起来;然后,总计数除以单词长度减去唯一字符的数量(加 1,以防止除以零)。
这不是一个令人惊讶的得分函数,但它很简单,并且以这样一种方式对单词进行加权,即更多的独特字符比具有更少独特字符的单词给予更大的权重。理想情况下,我们希望尽可能多的独特、频繁的字符,以最大化在 Wordle 中获得绿色或黄色匹配的可能性。
一项快速测试证实,含有不常用字符和重复字符的单词的权重低于含有常用字符和更独特字符的单词。
>>> calculate_word_commonality("fuzzy") 0.04604572396274344
>>> calculate_word_commonality("arose") 0.42692633361558
我们现在需要的是一种排序和显示这些单词的方法,以便人类玩家可以从中选择:
import operator def sort_by_word_commonality(words): sort_by = operator.itemgetter(1) return sorted( [(word, calculate_word_commonality(word)) for word in words], key=sort_by, reverse=True, ) def display_word_table(word_commonalities): for (word, freq) in word_commonalities: print(f"{word:<10} | {freq:<5.2}")
使用sort_by_word_commonality,我生成一个排序的(从最高到最低)元组列表,每个元组包含单词和该单词的计算得分。我排序的关键是分数。
我没有使用 lambda 来获取第一个元素;对于像这样简单的东西,我更喜欢做同样事情的operator.itemgetter。
我还添加了一个快速显示功能,将单词及其分数格式化成一个简单的表格。
现在是求解器。
编写 Wordle 求解器
因为我正在构建一个简单的控制台应用程序,所以我将使用input()和print()。
def input_word(): while True: word = input("Input the word you entered> ") if len(word) == WORD_LENGTH and word.lower() in WORDS: break return word.lower() def input_response(): print("Type the color-coded reply from Wordle:") print(" G for Green") print(" Y for Yellow") print(" ? for Gray") while True: response = input("Response from Wordle> ") if len(response) == WORD_LENGTH and set(response) <= {"G", "Y", "?"}: break else: print(f"Error - invalid answer {response}") return response
功能很简单。我想向用户询问他们给 Wordle 的一个WORD_LENGTH单词,我想记录 Wordle 的响应。由于只有三种可能的答案(绿色、黄色和灰色),我将其编码为一个简单的三字符字符串:G、Y和?。
我还添加了错误处理功能,以防用户反复循环输入错误,直到给出正确的序列。为此,我再次将输入转换为一个集合,然后检查该用户输入集合是否是有效响应的子集。
用词向量过滤绿色、黄色和灰色字母
绿色字母规则表明字母和在单词中的位置是正确的。黄色表示位置不对,但表示字母存在于单词中;格雷认为这封信不在任何地方。
另一种解释是,在沃尔多告诉我们哪些字母是绿色、黄色或灰色之前,所有的可能性都存在。
word_vector = [set(string.ascii_lowercase) for _ in range(WORD_LENGTH)]
这里我创建了一个集合列表,列表大小等于单词长度,即 5。每个元素都是一组全部小写的英文字符。通过为每个集合创建一个,我可以在从每个位置删除字符时删除它们:
Green letters are limited to just that letter
这意味着如果我在位置 2 遇到一个绿色的字母,那么我可以修改那个位置的集合,只保存那个字母。
Yellow letters imply the complement of that letter
所以所有的字母除了那个字母在那个位置技术上是可能的。将该字母从该位置的集合中移除确保我们不能选择该字母被设置为该字符的单词。
Gray letters imply the exclusion of that letter across the vector
因此,该字符必须从单词 vector 的所有集合中删除。
理想情况下,我们的 Wordle solver 将尝试找到尽可能多的绿色字母,因为这自然是最佳匹配类型。
现在我需要一个函数来告诉我一个单词是否匹配单词 vector。有很多方法可以做到这一点,但这是一个很好很简单的方法:
def match_word_vector(word, word_vector): assert len(word) == len(word_vector) for letter, v_letter in zip(word, word_vector): if letter not in v_letter: return False return True
这种方法使用zip来成对匹配单词中的每个字符,以及单词向量中的每个字符(如果有的话)
如果该字母不在该位置的单词向量集中,则以失败的匹配退出。否则,继续,如果我们自然退出循环,返回True表示匹配。
匹配单词
规则实现后,我们现在可以编写搜索函数,根据从 Wordle 返回的响应过滤单词列表。
def match(word_vector, possible_words): return [word for word in possible_words if match_word_vector(word, word_vector)]
匹配器将我们刚刚谈到的概念合并到一个列表理解中,进行检查。用match_word_vector对照word_vector测试每个单词。
重复答案
最后,我们需要一个小的用户界面,可以重复查询我们想要的答案。
def solve(): possible_words = WORDS.copy() word_vector = [set(string.ascii_lowercase) for _ in range(WORD_LENGTH)] for attempt in range(1, ALLOWED_ATTEMPTS + 1): print(f"Attempt {attempt} with {len(possible_words)} possible words") display_word_table(sort_by_word_commonality(possible_words)[:15]) word = input_word() response = input_response() for idx, letter in enumerate(response): if letter == "G": word_vector[idx] = {word[idx]} elif letter == "Y": try: word_vector[idx].remove(word[idx]) except KeyError: pass elif letter == "?": for vector in word_vector: try: vector.remove(word[idx]) except KeyError: pass possible_words = match(word_vector, possible_words)
solve 函数做了很多我已经解释过的设置。但是在那之后,我们循环到ALLOWED_ATTEMPTS + 1,并且随着每次尝试,我们显示我们正在进行的尝试以及还有多少可能的单词。然后我们调用display_word_table来漂亮地打印 15 个得分最高的比赛的表格。然后我们询问这个单词,以及 Wordle 对这个单词的响应。
接下来,我们枚举响应,确保记住每个答案的位置,这样我们就知道它在单词中指向哪里。代码很简单:我们将三个响应字符中的每一个映射到各自的容器(绿色映射到word_vector,等等)。)并应用我们之前讨论的规则。
最后,我们用来自match的新匹配列表覆盖possible_words,并再次循环,显示现在减少的子集。
尝试一下
The answers match the queries we gave to the solver.
通过调用solve()启动它(为了简洁省略了一些输出):
>>> Attempt 1 with 5905 possible words
arose | 0.43
raise | 0.42
... etc ...
Input the word you entered> arose
Type the color-coded reply from Wordle:
G for Green
Y for Yellow
? for Gray
Response from Wordle> ?Y??Y
Attempt 2 with 829 possible words
liter | 0.34
liner | 0.34
... etc ...
Input the word you entered> liter
Response from Wordle> ???YY
Attempt 3 with 108 possible words
nerdy | 0.29
nehru | 0.28
... etc ...
Input the word you entered> nerdy
Response from Wordle> ?YY?G
Attempt 4 with 25 possible words
query | 0.24
chewy | 0.21
... etc ...
Input the word you entered> query
Response from Wordle> GGGGG
Attempt 5 with 1 possible words
query | 0.24
摘要
Comprehensions are powerful Python tools
他们可以将迭代和过滤结合起来,但是如果你滥用这个特性,堆积太多的for循环,或者太多的if子句,你就冒着使你的代码变得非常非常难读的风险。避免每种嵌套超过几个。
Sets are a major asset to Python
采取行动的能力,以及知道何时使用集合成员资格的能力,使得代码更稳定、数学上更正确、更简洁。这在这里很有用——不要忽视布景!
You can express the entire search space with regular expressions
虽然我没有探究,但匹配(或不匹配)字符的行为是正则表达式做得最好的。想一个方法,你可以使用正则表达式重写匹配器和单词矢量化。
The itertools and collections module contain useful helpers
如果你知道如何使用内置模块,你可以用基本的 Python 完成很多事情。如果你想懒散地或迭代地计算数值,这尤其有用。
Game Boy 模拟器:设计 CPU
Author Mickey Petersen
在 Game Boy 模拟器:编写 Z80 反汇编器 中我们学习了如何编写一个指令解码器和反汇编器。这是编写 Game Boy 模拟器的重要的第一步。汇编语言——或者至少是它的二进制机器代码形式——是 CPU 的语言,因此我们必须在软件中表示一个真实 CPU 的复制品,它可以为我们执行这些机器代码指令。
因此,让我们从快速概述 CPU 到底是什么和做什么开始,以及我们将如何模拟它。
CPU 到底是什么?
什么是 CPU,怎样才能让一个 CPU——在这里,不严格地说——通用,足以让你编写任何你喜欢的程序?
事实证明,你需要的很少。对于计算机科学家来说,这既是一个思想实验,也是一个真正的研究焦点。这是一个有趣的领域,它分为许多不同的领域,围绕着计算理论和计算模型。今天广泛使用的计算机系统,包括我们的 Game Boy 系统,就是这些理论和计算模型的体现。
今天,我们几乎所有的计算机系统都是这些理论概念的实际实现:冯·诺依曼架构和寄存器机器的实用融合,这些寄存器机器是 ?? 图灵机的后代。
但是为什么现在对我们来说这很有趣呢?因为这些概念的源泉是一个通用的系统——具体来说,计算通用——足以表示和执行任何程序。这些图灵完备系统(或者在这样的系统上运行的编程语言)决定了我们是否可以编写任何我们想要的程序。
因此,从一系列的理论概念中,CPU 制造商已经有了一个蓝图,各种各样的蓝图,为我们这些开发者编写软件所必须具备的东西提供了理论基础——即使我们的内存和时间有限,不像他们的理论对手。这表现为一个指令集——你现在对它们有点熟悉了,因为你已经写了一个反汇编器和解码器——还有一些其他的基本概念,我一会儿会讲到。但是所有这些都在抽象层中达到顶点,我们都在抽象层上构建自己的软件。
一旦你理解了一个 CPU 是如何工作的,你就可以将这一知识应用到大多数其他 CPU 或虚拟机上。幸运的是,对于 Z80 CPU 来说,这并不太难。
如果你必须设计一个能够计算任何程序的最简单的 CPU,它必须有以下形式:
A stream of instructions to fetch, decode, and execute
有一个——可能是无限的 CPU 必须获取的指令流;解码;然后执行。每次执行都会改变 CPU 或外围设备(如内存条)的状态。
A method of keeping track of the current instruction the CPU is executing
当一条指令被执行时,CPU 必须以某种方式前进到它要读取的下一条逻辑指令。这可能是也可能不是紧跟在当前指令之后的指令。它可以是潜在的无限指令序列中的任何指令。CPU 在一个叫做的程序计数器或 PC 中跟踪它的位置。
当一条指令被解码时,它的程序计数器被提前。当指令被执行时,该指令可以依次改变程序计数器,比如向前或向后跳跃相对数量的位置,或者跳到绝对位置。
问问你自己为什么一个指令想要直接修改程序计数器
The ability to recall and store facts
一个这样的事实是程序计数器,因为它必须存储在某个地方。除此之外,您可能还有一些内存可供 CPU 使用。
通常情况下,像 PC 这样的东西被存储在一个寄存器中,其大小以位为单位。有些寄存器是通用寄存器,可以用于程序员想要的任何东西。其他的服务于特定的目的(像前面提到的程序计数器),帮助程序员完成特定的任务或作为 CPU 向程序员传达其状态的一种方式。寄存器的数量是有限的,每个寄存器都是稀缺资源。
由于寄存器对于大多数 CPU 设计的操作非常重要,因此它们具有针对其特定需求而定制的指令,以加快执行速度并节省大小(必须指定操作码应该使用的寄存器将为每条指令额外增加几个字节的存储空间。)
然而没有寄存器也能造 CPU!但这并不意味着 RAM 就是你从自己的电脑上看到的替代品。它可以是穿孔卡片,或者其他你可以存储和调用的媒介。
Basic programming concepts like arithmetic operations and conditional checking
算术不仅仅是加法和减法;并且条件检查通常被实现为减法的一种特殊形式:R = A - B,因此当R为零时,它表示相等。然后存储算术或条件运算的结果。其中取决于 CPU 架构。
A known, fixed state when the system is first started
这意味着程序计数器和 CPU 依赖的任何其他“状态”的静态起始位置。
An instruction set that serves as the language of the CPU
这是我们与 CPU 交互并告诉它需要做什么的方式。指令的数量变化很大,有些概念你可以用其他指令来表达,比如去掉加法,只用减法。
仅此而已。有了这些能力,你的 CPU 就足够通用来计算任何东西(当然,只受你的内存限制。)
事实上,有一种单指令集计算机是通用的,但只有一条指令,只要有足够的耐心,它可以用来构建任何东西。如果你仔细研究需求——实现这样一台计算机有几种方法——你会发现所有这些都需要你在上面看到的东西。即使你对计算理论不感兴趣,我也建议你快速浏览一下。这是一个非常显著的证据,证明了通用计算的成功只需要很少的条件。
让我们继续讨论 Z80 的功能。
Z80 CPU
登记
首先,您可以随意使用许多寄存器。您可能还记得,Z80 是带有 16 位地址总线的 8 位处理器。这意味着必须有一种方法既能处理 8 位数字又能处理 16 位数字,这种方法就是使用一种巧妙的设计,让您可以根据读取的寄存器,以 8 位或 16 位字的形式读取某些寄存器。
如果你进入 pandocs 的 CPU 寄存器和标志,你会看到一个寄存器及其名称的列表:
-
AF,由A组成,累加器寄存器;还有F,一个保存标志的内部寄存器。 -
BC,通用寄存器 -
DE,通用寄存器 -
HL,通用寄存器和它有大量简化迭代代码的指令,像循环;16 位算术;和数据加载、位操作等等。 -
SP,堆栈指针。用于调用堆栈并使 CPU 能够本地支持函数调用和返回值。有许多专门的指令,使它更容易做到这一点。 -
PC,程序计数器。用于跟踪下一条执行指令的位置。指的是一个内存地址。
值得注意的是“高”和“低”寄存器的概念。例如,通用寄存器BC由高字节B和低字节C组成,顾名思义。
这意味着您可以从BC请求完整的 16 位值,或者分别用B或C请求高 8 位或低 8 位。这非常漂亮,是一种非常有用的方法,可以有选择地对值的一部分而不是整个值进行操作。
例如,如果你有一个值BC = 0x1234,你可以把它解释为,或者是B = 0x12或者C = 0x34。
请记住,并不是所有的寄存器都支持这种操作模式,其中有一个寄存器尽管已经命名,但却是完全不可访问的。那是F寄存器;程序员只能通过其他指令间接地使用它。
将一个 16 位的字分成两个 8 位的块是一个聪明的机制,它可以让你计算比 CPU 本身更大的数字。
这是一个假设的例子,因为 Z80 确实带有一些 16 位指令,但让我们假设它一次只能推理大约 8 位数据。0 到 255。
如果您想要循环 5000 次,远远超过 CPU 物理上能够用一个字节跟踪的次数,该怎么办?你如何解决这个问题?
假设我们设置了BC=0,我们想在到达 5000 时停止,或者当BC = 0x1388:
-
检查
B = 0x13和C = 0x88是否。如果是,我们就完成了,可以退出循环。 -
将
C增加 1。 -
如果
C = 0x0(你会检查零标志,但这种解释是为以后!)然后将B增加 1。 -
转到 1
We need a way of storing and recalling the values of the Z80’s registers
因此,我们需要一种用 Python 表示所有这些寄存器的方法。幸运的是,这对于仿真器作者来说是微不足道的:我们有变量。因此,我们需要每个寄存器都有一个变量,而且还需要一个读写 16 位寄存器的高、低部分的方法。
旗帜
当 CPU 执行指令时,它有副作用。这些副作用中的一些可能会产生关于 CPU 在执行每条指令后发现自己所处的新状态的重要信息。例如,如果你要求 CPU 比较两个数字,它如何将比较指令的结果反馈给你?
答案是标志寄存器。如 pandocs 文档所示,有四个标志,每个标志占用AF的F寄存器部分的一个位。您可能会注意到,并非所有八位都映射到一个标志;其余未使用。
回头看看上一章对贪吃蛇游戏的反汇编:
1;; snake.gb disassembly 20150 NOP 30151 DI 40152 LD SP, 0xfffe 50155 LD B, 0x80 60157 LD C, 0x0 70159 LDH A, (0x44) 8015B CP 0x90 9015D JR NZ, 0xfa 10015F DEC C 110160 LD A, C 120161 LDH (0x42), A 130163 DEC B 140164 JR NZ, 0xf3 150166 XOR A 160167 LDH (0x40), A 170169 LD A, 0x0
注意第 8 行和第 9 行。
CP 0x90是比较指令。它与A进行比较,以便如果A - 0x90 = 0设置了零标志(在F中的位 7 为 1);否则它是未设置的。JR NZ, 0xFA是一条跳转指令,如果零标志未置位,则跳转。
术语可能略有不同。“设置”意味着一个位被使能,即它的值为 1。复位或不复位意味着它是 0。
然后,标志的作用是在指令执行后发生特定事件时通知程序员。一旦你开始模仿这些指令,每个标志的作用就会变得明显。
We need a way of setting and resetting bits in a bit field
Z80 使用了几个标志,它们都存储在F寄存器中,编码为位域中的单个位。您不能直接访问该寄存器。
指令解码器
在我们上一章写的解码器的基础上,CPU 需要一个指令流来获取、解码和执行。因为我们还没有完全准备好处理内存和内存库,所以我们将让实现保持原样,稍后再回头重新访问它。
一旦 CPU 准备就绪并开始运行,它必须将PC发送到解码器,这样它就可以从(后来的存储器,现在是我们读入的 rom 流)中获取指令,并返回PC的新地址,当然,还有 CPU 必须执行的指令:
-
在
PC向解码器询问指令 -
解码器获取并解码该指令,并将其连同流中的下一个逻辑位置一起返回。
-
CPU 执行指令(这可能会改变
PC,因为它是一个可读写的寄存器)
构建 CPU
是时候充实我们的框架类了,这样模拟器的核心就可以成形了。让我们从收银机开始。
代表寄存器和标志
正如我前面提到的,你需要把寄存器看作仅仅是变量。然而,唯一的两个障碍是 16 位字的高低概念,标志是存储在AF低位字中的位域。
因此,如果你有一个 16 位的数字0xABCD并且你想要高位和低位字分开,你将需要旋转一些位,正如表达式所说。
钻头操作快速入门
我将快速介绍我们需要的内容,但是一旦我们深入查看一些说明,就会有更深入的探讨。话虽如此,我还是建议你尝试一下,因为直觉理解是必不可少的。
因此,要获取或设置任意位,我们需要了解一些关于二进制和位运算的基本知识。考虑数字0xAB的二进制表示:
+-----+-----+-----+-----+-----+-----+-----+-----+
| 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
+-----+-----+-----+-----+-----+-----+-----+-----+ = 0xAB
| 1 | 0 | 1 | 0 | 1 | 0 | 1 | 1 |
+-----+-----+-----+-----+-----+-----+-----+-----+
假设我想要0xAB的0xA。我可以通过右移四位来实现这一点,因为我希望 8 位(即 4 位)中的高电平部分:
>>> hex(0xAB >> 4) '0xa'
因为大多数计算机从右向左计数,并且因为移位将位 N 位向左或向右移动,所以右移位有效地擦除了它所替换的位:
+-----+-----+-----+-----+-----+-----+-----+-----+
| 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
+-----+-----+-----+-----+-----+-----+-----+-----+ = 0xA
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
+-----+-----+-----+-----+-----+-----+-----+-----+
Shift right 4 -> \-----------------------/
当我们向右移动时,这些值被填充为 0,因此是多余的,就像用十进制写00042和42一样。
与之相反的是左移:
>>> hex(0xA << 4) '0xa0'
向左移动,然后再向你移动的方向移动。但是,请注意新的值0xA0。就像十进制数乘以 10 加一个零一样,左移也是如此。
那么,左移相当于乘法,右移相当于 2 的幂的除法*。所以,一个除了位移位和加减运算之外什么都不会的有事业心的程序员,即使 CPU 不支持这样的运算,也能模拟乘除运算。*
试着用 2 的乘方数进行除法和乘法来复制位移。
这就是高潮词。如果我想要更低的单词呢?我不能移动,因为那会把号码抹掉。相反,我需要一种提取这些信息的方法——一种能让我们更好地前进的方法。
获取您想要的位的一种方法是询问我们想要的位是否被设置,然后返回它们。多亏了按位&运算符和一个叫做位屏蔽的概念,这很容易做到。
比特屏蔽
位运算的工作方式很像在 if 语句中使用的逻辑运算。逐位运算对每一位进行操作,并对每一对位进行逻辑and运算。
+-----+-----+-----+-----+
| 8 | 4 | 2 | 1 | Bit
+-----+-----+-----+-----+
| 0 | 1 | 1 | 0 | A
+-----+-----+-----+-----+ &
| 1 | 0 | 1 | 1 | B
+-----+-----+-----+-----+ =
| 0 | 0 | 1 | 0 | C
+-----+-----+-----+-----+
>>> 0b0110 & 0b1011 2
想想当你把这两个数按位&在一起时会发生什么。因为逻辑and按顺序应用于每个位,所以结果是这些按位运算的结果。
然后一个聪明的程序员可以使用一个掩码——一个数字——只返回他们需要的比特。从例子中可以看出,A和B中只有位 2 为1,所以结果为2,
所以一个面具是有用的,如果你想得到(或设置!)位。
Masks traditionally go on the right-hand side
这不是一个严格的规则,但是如果你硬编码常量(你将在后面看到),习惯上使用右边的掩码和左边的值进行掩码。
如果你因为风格的原因或者为了复制一个算法而反过来做也没问题,但是要保持一致。
检查是否用按位 AND 设置了一个或多个位
因此,如果我想要低位,我可以使用按位&操作符挑选出我想要的位。
给定数字0xAB,我可以用设置了 1、2、4 和 8 位的掩码得到较低的四位:
>>> 0b1111 1
当我用 ?? 表示 ?? 时:
>>> hex(0xAB & 0b1111) '0xb'
这就是我们想要的答案。
用按位“或”设置位
问问你自己什么是逻辑or——如果左边或右边的一个或两个是True,它返回True。这也适用于按位|。
再次应用掩码的概念,您可以使用相同的概念来设置位:
+-----+-----+-----+-----+
| 8 | 4 | 2 | 1 | Bit
+-----+-----+-----+-----+
| 0 | 0 | 1 | 0 | A
+-----+-----+-----+-----+ |
| 1 | 0 | 1 | 1 | B
+-----+-----+-----+-----+ =
| 1 | 0 | 1 | 1 | C
+-----+-----+-----+-----+
给定一个掩码,我们现在可以用|任意设置位。
>>> bin(0b0010 | 0b1011) '0b1011'
其中0b1011实际上是两个输入的按位|。
用按位异或切换位
XOR 或 eXclusive OR 在 Python 中没有逻辑对应物。它的用途主要是,但不总是,归入逐位运算或作为算法中的专业工具,因为它拥有一个有趣的特性 。
By the way …
由于其独特的转换位的能力,这一特性使得创造密码无法破解的一次性密码本成为可能。
也就是说,XOR ( ^)的结果只有非零(记住,这里没有布尔运算!)如果左侧或右侧的之一非零。即,与按位 AND 不同,两边都不能非零或为零;与按位“或”不同,只有一边可以为零,另一边必须非零。
再次应用遮罩的概念,让我们切换一些位
+-----+-----+-----+-----+
| 8 | 4 | 2 | 1 | Bit
+-----+-----+-----+-----+
| 0 | 0 | 1 | 0 | A
+-----+-----+-----+-----+ ^
| 1 | 0 | 1 | 1 | B
+-----+-----+-----+-----+ =
| 1 | 0 | 0 | 1 | C
+-----+-----+-----+-----+
>>> bin(0b0010 ^ 0b1011) '0b1001'
请注意我们如何切换这些位,除了值和掩码都是0的位。
Bitwise operators return the result of the operation
我们得到的不是True或False的答案,而是运算的结果。很有用,那个!
Bitwise operators and masks are useful tools to get, set, reset, or toggle bits
掩码是有用的工具,任何东西都可以是掩码,包括您想要测试的另一个值。
按位 AND 检查是否设置了某些内容;按位“或”用于设置位;按位异或用于切换位
You can combine bit shifting with bitwise operators
这允许您将单个位移动到您想要检查的位置。试试1 << 7,看看它的二进制,十进制,十六进制形式。
寄存器和标志
好了,有了位的基础知识,现在是表示寄存器的时候了。你可以使用一个简单的字典,或者一个类的显式变量或属性——这取决于你。
我将使用修改过的字典式符号,因为它便于阅读和推理。但是无论如何,选择一个你认为对你最有意义的方法。
让我们从我们需要的寄存器和标志的一些映射开始,以及它们如何相互关联。
REGISTERS_LOW = {"F": "AF", "C": "BC", "E": "DE", "L": "HL"} REGISTERS_HIGH = {"A": "AF", "B": "BC", "D": "DE", "H": "HL"} REGISTERS = {"AF", "BC", "DE", "HL", "PC", "SP"} FLAGS = {"c": 4, "h": 5, "n": 6, "z": 7}
这些常量将低位寄存器(如C)映射到BC。这样,我们的代码可以通过一个简单的 if 语句链来检查寄存器是低位、高位、16 位寄存器还是标志。
from collections.abc import MutableMapping @dataclass class Registers(MutableMapping): AF: int BC: int DE: int HL: int PC: int SP: int def values(self): return [self.AF, self.BC, self.DE, self.HL, self.PC, self.SP] def __iter__(self): return iter(self.values()) def __len__(self): return len(self.values())
在这里,我将为一个定制的字典风格的类打下基础。我继承了一个抽象基类MutableMapping,它确保我覆盖了所有正确的方法来提供字典式的访问(registers["PC"] = 42)。)
我还使用了一个数据类,这样每个主寄存器都是构造函数的一部分。
接下来,是时候定义访问器了:
def __getitem__(self, key): if key in REGISTERS_HIGH: register = REGISTERS_HIGH[key] return getattr(self, register) >> 8 elif key in REGISTERS_LOW: register = REGISTERS_LOW[key] return getattr(self, register) & 0xFF elif key in FLAGS: flag_bit = FLAGS[key] return self.AF >> flag_bit & 1 else: if key in REGISTERS: return getattr(self, key) else: raise KeyError(f"No such register {key}")
该守则是故意说教;但是意图很简单。我依次检查每个寄存器集合,当我找到它时,我根据需要应用正确的位运算:
-
高位寄存器向右移位 8 位,得到我们想要的值
-
低位寄存器与掩码
0xFF进行逐位“与”运算,因为它与低位 8 位相匹配。 -
相反,标志是根据它们在
AF中的位置移动的,所以我们关心的位被放在最右边的位置,在那里我可以用1进行位与运算,以检查它是否被置位。 -
对 16 位寄存器的请求只是简单地返回该值,无需修改。
-
其他一切都是一个
KeyError
设置一个寄存器或多或少与获取是一样的,但是按位运算符现在是|。
def __setitem__(self, key, value): if key in REGISTERS_HIGH: register = REGISTERS_HIGH[key] current_value = self[register] setattr(self, register, (current_value & 0x00FF | (value << 8)) & 0xFFFF) elif key in REGISTERS_LOW: register = REGISTERS_LOW[key] current_value = self[register] setattr(self, register, (current_value & 0xFF00 | value) & 0xFFFF) elif key in FLAGS: assert value in (0, 1), f"{value} must be 0 or 1" flag_bit = FLAGS[key] if value == 0: self.AF = self.AF & ~(1 << flag_bit) else: self.AF = self.AF | (1 << flag_bit) else: if key in REGISTERS: setattr(self, key, value & 0xFFFF) else: raise KeyError(f"No such register {key}") def __delitem__(self, key): raise NotImplementedError("Register deletion is not supported")
-
设置高电平寄存器是这样一种情况:取值并将其左移 8 位到高电平位置,然后用掩码清除以前的值。我使用按位
|来确保我们保留寄存器中的其他内容(current_value -
对于低位寄存器,我简单地应用按位 or,因为不需要移位。
-
对于 16 位寄存器,设置值很简单。
-
对于标志,我们将需要复位的位(
value == 0)移动到正确的位置,然后使用补码 (~)翻转所有位,然后用AF屏蔽。如果value == 1那么我使用按位或。 -
其他一切都是一个
KeyError。
你可能已经注意到了每个作业末尾的& 0xFFFF。这是防止值溢出主寄存器 16 位大小限制的安全措施。我们正在编写的仿真器每个寄存器有 16 位,但是 Python 并不关心这个。屏蔽0xFFFF确保我们永远不会溢出。
为什么那个口罩会起作用,它是如何防止溢出的?试着给0xFFFF加 1,看看屏蔽前后的二进制表示。
现在做一些测试。我再次使用 假设来生成策略 :
import hypothesis.strategies as st from hypothesis import given @pytest.fixture(scope="session") def make_registers(): def make(): return Registers(AF=0, BC=0, DE=0, HL=0, PC=0, SP=0) return make @given( value=st.integers(min_value=0, max_value=0xFF), field=st.sampled_from(sorted(REGISTERS_HIGH.items())), ) def test_registers_high(make_registers, field, value): registers = make_registers() high_register, full_register = field registers[high_register] = value assert registers[full_register] == value << 8
如果你不熟悉 pytest 夹具,或者你看到的特定模式,看看py test 工厂夹具模式 。
测试很简单。我创建了一个Registers类,并通过手动移动它并检查 16 位大小的寄存器来测试我们设置的值——它是由假设随机抽取的——是我们所期望的。(这就是为什么保持寄存器分离并在测试中易于重用是值得的。)
既然您已经熟悉了测试的结构以及如何验证您的结果,那么测试低尺寸和全尺寸寄存器就足够容易了。试着自己写。
测试标志也同样简单:
@given( starting_value=st.integers(min_value=0, max_value=0xFF), field=st.sampled_from(sorted(FLAGS.items())), ) def test_flags(make_registers, starting_value, field): flag, bit = field registers = make_registers() outcome = [] for value in (0, 1): # Set attribute directly registers.AF = starting_value registers[flag] = value outcome.append(registers["F"]) assert registers["F"] >> bit & 1 == value assert outcome.pop() != outcome.pop()
最终的结果是一个简单的 dict 风格的类,允许您使用它们的名称查询低位、高位、标志和满寄存器。
中央处理器
CPU 是模拟器的核心部分,所以它必须是可扩展的,因为我们会随着时间的推移添加它;为了便于测试,部件必须易于换入或换出;它应该封装 CPU 的状态,这将包括一些我们还没有谈到的东西。
除了函数之外,您可以什么都不用做,但是为了保持到目前为止的风格,我将使用一个 dataclass 来表示 CPU。
class InstructionError(Exception): pass @dataclass class CPU: registers: Registers decoder: Decoder def execute(self, instruction: Instruction): # I'm using 3.10's pattern matching, but you can use a # dictionary to dispatch to functions instead, or a series of # if statements. match instruction: case Instruction(mnemonic="NOP"): pass case _: raise InstructionError(f"Cannot execute {instruction}") def run(self): while True: address = self.registers["PC"] try: next_address, instruction = self.decoder.decode(address) except IndexError: break self.registers["PC"] = next_address self.execute(instruction)
很简单。我再次使用了依赖注入模式。这意味着 CPU 不应该知道如何创建或实例化它所依赖的现存类。要使用它,你必须传递Decoder(你在上一章创建的)和Registers的CPU构造器实例。
我喜欢这种模式,因为它分离了关注点,所以每个类只知道它需要知道的东西,*,仅此而已!*因此,如果我们愿意,我们可以传入FakeRegisters或FakeDecoder来模拟我们想要测试的特定行为,甚至是替代实现。
但是如您所见,实现相当稀疏。然而,这足以开始执行指令。目前还没有内存条控制器,也没有显示屏或我们需要的任何其他花哨功能,但这很好。我们以后再去找他们。但是因为我们一丝不苟地一次实现了一个部分,所以现在需要将我们到目前为止构建的部分整合在一起:
Instruction fetching and decoding is (mostly) done
多亏了单一的decode方法,我们现在可以从字节流中获取指令;解码指令;将流推进到下一个位置;并返回解码后的指令。
CPU 现在处于利用这些信息的最佳位置。我们知道下一个逻辑指令(next_address)的地址,并且可以相应地更新PC寄存器。
Instruction Execution is the next big step
我将使用 Python 3.10 的 匹配和 case 关键字 ,但是如果你没有那个版本或者不想使用那个特性,也不用担心。您可以使用很多很多 if 语句,或者使用字典分派给执行的函数。
考虑如何存储从操作码文件加载的指令。您现在需要一种方法来调用能够处理该指令的函数。
我添加了一个单独的指令,NOP,它不做任何事情(它意味着不操作)
run()方法有一个无限的while循环:一旦我们开始,我们就不会停止,除非我们用完了所有的指令——因此有了IndexError检查——或者 CPU 被指令或人告知停止。
没有太多要测试的,所以让我们看看是否可以执行一系列任意的NOP指令:
@pytest.fixture(scope="session") def make_cpu(make_registers, make_decoder): def make(data, pc=0): cpu = CPU(registers=make_registers(pc=pc), decoder=make_decoder(data=data)) return cpu return make @given(count=st.integers(min_value=0, max_value=100)) def test_cpu_execute_nop_and_advance(make_cpu, count): cpu = make_cpu(b"\x00" * count) assert cpu.registers["PC"] == 0 cpu.run() assert cpu.registers["PC"] == count
仅此而已。我们的骨架 CPU 完成了。它可以根据需要读写每个寄存器和标志;它可以执行指令。
摘要
CPU architecture is similar to their theoretical counterparts in Computer Science
了解一点计算理论是有帮助的。它解释了为什么事情是这样的,以及 CPU(任何 CPU)运行的最低要求是什么。
Knowing your way around bitwise operations is important
它构成了真实 CPU 中发生多少计算的基础,尽管我们只是简单地看了一下。
The value of testing is ever-more important
假设让我们专注于捕捉我们所写的一切都必须遵循的属性和规则系统。没有假设,您仍然可以测试您的仿真器,但是您现在必须生成测试用例以及答案。
五种高级 Pytest 夹具模式
Author Mickey Petersen
pytest 包是一个很好的测试工具,它有一系列的功能,其中包括 ?? 夹具 ??。pytest fixture 允许您生成和初始化测试数据、伪对象、应用程序状态、配置等等,只需一个装饰器和一点小聪明。
但是 pytest 装置并不像看上去那样简单。这里有五个先进的夹具提示来改善你的测试。
工厂设备:带参数的设备
向 fixture 传递参数是人们开始使用 fixture 时想做的第一件事。
但是初始化硬编码数据是这样做的前提,这也是@fixture decorator 擅长的:
import pytest @pytest.fixture def customer(): customer = Customer(first_name="Cosmo", last_name="Kramer") return customer def test_customer_sale(customer): assert customer.first_name == "Cosmo" assert customer.last_name == "Kramer" assert isinstance(customer, Customer)
但是因为 pytest 只是传递对象——用少量的魔法使其在幕后正常工作——所以您可以返回任何东西——包括一个工厂,它允许您通过传递带有您想要的值的参数来控制您的测试数据如何初始化:
import pytest @pytest.fixture def make_customer(): def make( first_name: str = "Cosmo", last_name: str = "Kramer", email: str = "test@example.com", **rest ): customer = Customer( first_name=first_name, last_name=last_name, email=email, **rest ) return customer return make def test_customer(make_customer): customer_1 = make_customer(first_name="Elaine", last_name="Benes") assert customer_1.first_name == "Elaine" customer_2 = make_customer() assert customer_2.first_name == "Cosmo"
这是一个非常强大的模式;新的 fixture 现在被命名为make_customer,以使人们清楚地看到它是一个工厂制造的东西,它允许你覆盖first_name和last_name参数,而不是硬编码它们。但是如果你不在乎它们在某个特定的测试中是什么,你可以不考虑它们。
它的工作原理是这样的:它不是返回一个Customer的实例(就像第一个例子演示的那样),而是返回一个函数(称为make)来为我们完成所有繁重的工作。即功能是工厂;它负责创建最终对象并返回它。
Clearly name your factory fixtures
你完全可以为工厂设备使用通用名称,比如customer,但是我建议你不要这样做。相反,应该让 fixture 的用户——很可能是你以外的人——首先实例化它,而不只是假设它返回一个Customer的实例。
Initializing a fixture with static values is easy, but passing arguments requires a function
您可以通过返回一个函数来创建带有参数的 fixtures 这个函数被称为一个工厂。
组合夹具
考虑一个虚构的电子商务商店的测试套件中的两个独立装置。现在,假设您想要表示一个交易的概念,即一个客户完成了一笔销售,您可以通过创建如下 fixture 来实现:
@pytest.fixture def make_transaction(): def make(amount, sku, ...): customer = Customer(...) sale = Sale(amount=amount, sku=sku, customer=customer, ...) transaction = Transaction(sale=sale, ... ) return transaction return make
但是,您可以利用 pytest fixtures 可以反过来依赖于其他 fixture 的事实,而不是重复不必要的内容:
1import pytest 2 3 4@pytest.fixture 5def make_transaction(make_customer, make_sale): 6 def make(transaction_id, customer=None, sale=None): 7 if customer is None: 8 customer = make_customer() 9 if sale is None: 10 sale = make_sale() 11 transaction = Transaction( 12 transaction_id=transaction_id, 13 customer=customer, 14 sale=sale, 15 ) 16 return transaction 17 18 return make
这个夹具有一个强制transaction_id和一个可选customer和sale。如果后两个没有被指定,那么它们是由 fixture 通过调用它们各自的fixture 自动创建的。
使用闭包和monkeypatch的双向数据绑定
有时,您需要模仿、伪造或删除部分代码,以简化代码其他部分的测试。通常是为了实现某个目标,比如模拟罕见的错误情况,或者没有这些技术就无法轻松重现的场景。
pytest 中的monkeypatch特性允许您通过测试来完成这项工作,但是您也可以在夹具中完成这项工作。当您修补一段代码时,您可能希望检查它至少是用预期的参数调用的。如果打补丁的函数隐藏在其他逻辑的深处,使得直接查询变得困难或不可能,那么这一点尤其重要。
这就是为什么许多开发人员选择将 monkey 修补的代码放在测试中,而不是放在 fixture 中:您可以直接控制修补的代码并询问它的状态,但是有一种简单的方法可以用 fixture 做到这一点:
1@pytest.fixture 2def mock_send_email(monkeypatch): 3 sent_emails = [] 4 5 def _send_email(recipient, subject, body): 6 sent_emails.append((recipient, subject, body)) 7 return True 8 9 monkeypatch.setattr("inspired.order.send_email", _send_email) 10 return sent_emails
在这里,我在我们的假电子商务代码库中的某个地方修补了一个真正的send_email函数。让我们假设如果它发送一封电子邮件,它将返回True。但是如果你想确认它是不是叫做的*,你需要做更多的跑腿工作。所以,我用一个模仿的版本来修补它,这个版本将发送的电子邮件捕获到一个列表中sent_emails。但是我也返回同一个列表!*
这种方法有效是因为:
The _send_email mock function is lexically binds sent_emails to itself
这意味着_send_email函数保留了sent_emails对象,即使当它被修补到我们在inspired.order.send_email的“真正的”电子商务代码库时,函数的范围发生了变化。
Everything is an object
其中当然包括函数和列表。所以当我返回sent_emails时,我实际上是在返回我们的测试可以访问的同一个列表对象。
最终的结果是,我可以在任何需要的时候查询sent_emails列表。
比方说,我想测试一下commit_order是否可以接受Transaction的一个实例——其中包含对一个customer和一个sale以及一个transaction_id的引用——并发送一封电子邮件(以及在真实的电子商务系统中您希望它做的任何内务处理):
1def test_send_email(make_transaction, mock_send_email): 2 assert mock_send_email == [] 3 transaction = make_transaction(transaction_id="1234") 4 # Commit an order, which in turn sends a receipt via email with 5 # `send_email` to the customer. 6 commit_order(transaction=transaction) 7 assert mock_send_email == [ 8 ( 9 "test@example.com", 10 "Your order number 1234", 11 "Thank you for buying...", 12 ) 13 ]
正如您所看到的,list 对象是可用的,只要它被 fixture 中的模拟函数_send_email改变,它的状态就会改变。在这个例子中,数据流是单向的:从模拟函数到测试调用者,但是你可以很容易地反过来,修改测试中的列表,并把它的变化反映到模拟函数中。
monkeypatch is itself a fixture
您可以将经常被恶意修补的代码部分抽象到一个 fixture 中,然后使用它。这是一个很棒的抽象工具,它集中了一些很容易以难以调试的方式搞砸的东西。
You can combine this pattern with the factory pattern
并获得简化复杂或繁琐的实例化模式加上双向数据绑定的好处。
Two-way data binding with closures is a useful tool in your toolbox
当然,这个例子很简单,但是对于复杂的对象或状态层次结构来说,跟踪整个系统中发生的变化的能力是一个有用的模式。自然,这个想法不仅仅适用于设备或测试代码。
The existing unittest.mock can do this also
如果你更喜欢经典的unittest.mock方法,你可以和@patch、MagicMock.assert_called_once_with()以及朋友一起做。然而,我更喜欢这种方法,因为它是显式的。唯一的魔法就是打补丁;剩下的就是 Python 了。对于你可以在你的补丁函数中写什么没有限制,对于你返回什么也没有限制,对于你选择如何在 fixture 和 test 之间形式化契约也没有限制。
用yield拆卸和安装夹具
如果您在 fixture 内部,您可以在那个时间点返回一个作用域对象或值,pytest 将只在测试完成后恢复生成器。您可以使用此模式创建传统的安装和拆卸模式:
@pytest.fixture def db_connection(): connection = create_database_connection(host="localhost", port=1234) try: connection.open() yield connection finally: connection.close()
这里我创建一个连接对象,open()它,然后yield它进行测试。当测试出于任何原因退出时,运行finally子句,然后连接。
这种方法的一个不幸的问题是,它不适用于工厂模式。要解决这个问题,您可以使用 pytest 的request.addfinalizer()函数将它们组合起来:
@pytest.fixture def make_db_connection(request): def make(host: str = "localhost", port: int = 1234): connection = create_database_connection(host=host, port=port) connection.open() def cleanup(): connection.close() request.addfinalizer(cleanup) return connection return make
像前面的工厂模式一样,我用从make中提取的host和port参数值返回一个实例化的连接。我打开连接——和以前一样——但是为了确保在测试完成时进行清理,我将cleanup添加到 pytest 的addfinalizer中。
这有点复杂,但是它确保了资源的创建和销毁之间的清晰分离。
现在你可能想知道为什么我不能从内部make进入yield?嗯,你可以,而且它工作正常…但是当测试退出时,你不会得到自动清理。原因是如果是一个生成器函数,pytest 只清理make_db_connection fixture ,但它不是…所以它不清理。
Use yield to manage teardown and setup of application state, like database connections or files
如果你不需要工厂模式,它工作得很好,你可以产生任何你喜欢的东西:一个对象元组,如果那是你需要的。
我建议您在try和finally中完成安装和拆卸,即使 pytest 做出了一些保证,如果 pytest 崩溃,它会尝试清理。
You can use request.addfinalizer if you have especially complex requirements
它适用于任何东西,如果有必要,你也可以从测试中调用它。这也是将工厂模式与我演示过的其他模式配对的最简单的方法,比如双向绑定。
用monkeypatch触发副作用和错误
与unittest.mock库不同,monkeypatch工具出奇的简单和轻量级。部分原因是你可以随心所欲地自由使用现有mock库的部分内容;但是我认为这只是一种不同于你用monkeypatch得到的嘲讽和修补的方法。
在 pytest 出现之前,mock库有大量复杂的特性和怪癖,我们大多数专业使用 Python 的人都已经习惯了。但是如果你能用monkeypatch让事情变得简单,干净利落地解决问题,你应该这么做。
def commit_order(transaction): # Checks the stock levels and returns the count # remaining and an error if it's out of stock. check_stock_levels(transaction.sale.product) # Saves the transaction the DB and raises an # error if its transaction ID already exists save_to_db(transaction) # ... do a bunch more stuff ... # Send a thank-you email send_email( first_name=transaction.customer.first_name, # .. etc ... ) return True
让我们回到电子商务订单系统。假设我们想测试——从电子商务前端的角度,比如说 UI 或 REST API——如果commit_order(transaction)以某种方式触发了一个错误,会发生什么。现在让我们假设这是订单系统的主要入口点:它完成所有的数据库工作;它发送电子邮件;它检查和更新库存——它有许多活动部件,是机器中的一个重要齿轮。
让我们具体模拟几个潜在的错误:
-
在用户点击“立即订购”的时间内,以及在系统能够核对其库存之前,一件商品销售一空。或者可能在履行中心的另一端有一个人用“缺货”更新一个交易。
-
不知何故,由于一次偶然的重复购买,一笔重复交易成功了。有一个适当的检查来防止数据库中的重复条目,例如数据库级约束。
在这种情况下,我们需要一种方法来测试这些场景是否被正确处理。通常你会写一个详尽的测试套件来重现它们,这很好,但也许你正在测试代码的其他部分,就像我们这里一样,以及它们如何与错误状态交互。或者也许你正在构建一个夹具,使得可以任意触发这些案例,这样你团队中的其他开发人员可以将它用于其他测试案例。无论哪种情况,都是一样的情况。
@pytest.fixture def mock_fulfillment(monkeypatch): state = {"out_of_stock": False, "known_transactions": set()} def _check_stock_levels(product): if state["out_of_stock"]: raise OutOfStockError(product_id=product.sku) else: return product.stock def _save_to_db(transaction_id): if transaction_id in state["known_transactions"]: raise DuplicateTransactionError(transaction_id=transaction_id) return False monkeypatch.setattr("inspired.order.save_to_db", _save_to_db) monkeypatch.setattr("inspired.order.check_stock_levels", _check_stock_levels) return state
所以你想要的是一个专门设计的夹具来修补commit_order–而不是的关键部分——来引发这两种错误场景。我想要的是一个功能开关来测试之前/之后的条件,并确保系统正确处理它们。
上面的例子通过重用早期的几个模式实现了这一点。它不是一个列表,而是一个字典,包含了系统应该反映的状态。在你自己的代码库中,用这个来代替任何数量的情况。
现在是匹配测试:
def test_commit_order(mock_fulfillment, make_transaction): transaction = make_transaction(transaction_id=42) assert not mock_fulfillment["out_of_stock"] assert commit_order(transaction) # Now again, but this time we test an out of stock event: with pytest.raises(OutOfStockError): mock_fulfillment["out_of_stock"] = True commit_order(transaction)
我认为这是不言自明的。通过修改state,我可以通过拨动开关来引发一个错误。事实上,正如我们所预期的,模拟函数会遵守并引发OutOfStockError。
通过有选择地只修补需要修补的代码部分,你可以最小化修补过度的可能性。这在现实生活中太常见了。您可以使用多个 fixturess(每个 fixture 都有或没有错误状态)和多个测试轻松做到这一点。但是如果你有一个足够复杂的交互集,可能是不可行或不可维护的。
当然,你也可以用mock.Mock()中的side_effect特性来做这件事。
我喜欢这种方法,因为它更接近于这样一种思想,即我们一个函数一个函数地交换,并且函数具有最少量的逻辑,您可以随着需求的增长随时修改。很容易以菊花链的方式结束Mock()对象,如果结构稍有变化,就不得不重构所有对象。或者,更糟糕的是,由于对象是如此的“灵活”,你的代码可能已经从根本上改变了,并且以某种方式破坏了,但是你的测试仍然是绿色的!
摘要
Fixtures are not just there to initialize simple objects
但是,当然,如果你不需要更多——很好。简单是件好事。但是,如果您处理大型或复杂的代码库,这可能还不够。
You, the developer, determine the relationship and contract you have with a fixture
我已经展示了您可以使用双向绑定来实现被模仿函数内部的更改;但是,它增加了复杂性。我发现当选择是十几个测试时,复杂性是可管理的,每个测试都足够不同以保证新的测试或新的夹具来支持它,但是没有任何人尝试和重构他们如何开始测试的动力。
双向绑定和工厂模式对于解决您最终在实际代码库中遇到的一些杂乱无章的测试套件大有帮助。
Don’t forget unittest.mock
monkeypatch装置是有意简单的,可能是对mock的混乱和复杂程度的谨慎过度反应。但令人欣慰的是,它迫使 Python 开发人员用魔法换取显式代码,即使词法范围和可变性增加了复杂性。
Game Boy 模拟器:编写 Z80 反汇编程序
Author Mickey Petersen
让我们继续我们在 对 Game Boy 仿真 的介绍中停止的地方,深入探究 Game Boy 的操作码和操作数 Z80 CPU 的语言——以及如何理解这一切。
正如你所记得的,Z80 是一个 8 位 CPU,有 16 位指令可供选择,每个指令都有一个相关的操作码和零个或多个该指令使用的操作数。
稍后,我们将实现每条指令的细节,但在此之前,我们需要了解游戏机如何将信息传递给 CPU 进行处理;为了理解这一点,在继续编写模拟器的第一部分:反汇编器之前,我们先快速浏览一下什么是来自的 cartridge。
什么是 Game Boy 卡带 ROM?
First-generation Game Boy Cartridge. The cartridge is slotted into the back of the Game Boy.
当你把一个盒子放进游戏机的背面时,它会不知何故地启动,并开始游戏。Game Boy 的卡带差别很大,这取决于它是为哪种游戏制作的,它是在哪个时代制作的,以及它的开发者是谁。
它们都有某种形式的游戏代码存储。一些较大的游戏有不止一个芯片,因此需要一个内存条控制器,因为 Game Boy 只有一个 16 位地址总线。游戏可以根据需要在不同的芯片之间切换。后来的几代产品从相机附件到加速度计无所不包。这些功能中的每一个都将依次简单地写入专用的内存区域,游戏男孩可以依次读取并使用游戏代码。简单,但是有效。
有些还配备了某种主存储器,用于存储高分和保存游戏等内容,以及一个小电池,用于为所述芯片充电,以防止数据丢失。
完全展开后,盒式磁带的有效存储容量从 32 KiB 到几兆字节不等。
这是一个弹药筒。一个ROM——ROM 是只读存储器——是模拟器圈子里的一个通用术语,用来描述一个盒式磁带、软盘、CD-r om——实际上是任何东西——的克隆,其布局格式是模拟器编写者已经同意的格式。对于更简单的事情,这是 1:1 的映射。某处芯片中的一个字节;你电脑上一个文件中的一个字节。Game Boy 墨盒大多是这样工作的,这对我们来说是个好消息。
首先,实际上在相当长的一段时间内,我们不会太担心复杂的记忆库切换,而是专注于那些没有拥有的游戏。它们以两种方式中的一种容易识别:大小是32 KiB 确切地说是,另一种我们将在稍后讨论如何读出盒式 ROM 元数据时讨论。
Cartridge ROMs are byte-accurate ROM images of the cartridge’s chips
因此,盒式只读存储器就是从物理盒式存储器中的一个或多个芯片中取出的一系列字节。这正是我们想要的表示,因为它很容易推理。
读取盒式只读存储器的元数据
页眉、页脚和十六进制转储
每个墨盒都有一个被称为墨盒标题的保留内存区域。大多数二进制文件格式都有一个文件头;有些还带有一个页脚,表示文件格式可读部分的结束。非常复杂的甚至可能有格式中的格式。
您可以在 Linux 上使用xxd hexdump 工具自己测试这一点(在本系列的后面,我们将为我们的交互式调试器编写自己的工具。)
如果你没有xxd工具,我建议你下载一个免费的十六进制编辑器。你也可以在这里下载该工具编译后的可执行文件。
如果您以字节读取模式打开文件,并使用hex(),您也可以用 Python 轻松地做到这一点;format()带%x的琴弦;或者用 f 弦,像这个{variable:x}。
如果你使用 Emacs,你只需输入M-x hexl-find-file。
以下是 ZIP 文件的前八个八位字节:
$ xxd -l 8 test.zip
00000000: 504b 0304 1400 0000 PK......
^^^^^^^^
Offset ^^^^^^^^^^^^^^^^^^^
Hexadecimal representation ^^^^^^^^
Textual/Byte representation
前两个字节表示“PK”,以 PKZip 和 Zip 格式的创始人菲利普·卡兹命名。大多数文件都可以这样做。试试看。
然而,如果你用一个盒式磁带试一下,你会大吃一惊:盒式磁带 ROM 的开头实际上并不是头文件。
要在 ROM 中找到标题的位置,打开 Pandocs 并选择盒式标题。如文档所述,割台位于偏移量0x100。
卡片盒头还包含文字代码,而不仅仅是数据——稍后会详细介绍。
所以,让我们在家酿中心的上试试吧:
$ xxd -s $((0x100)) -l $((0x0150 - 0x100)) snake.gb
00000100: 00c3 5001 ceed 6666 cc0d 000b 0373 0083 ..P...ff.....s..
00000110: 000c 000d 0008 111f 8889 000e dccc 6ee6 ..............n.
00000120: dddd d999 bbbb 6763 6e0e eccc dddc 999f ......gcn.......
00000130: bbb9 333e 5976 6172 2773 2047 4220 536e ..3>Yvar's GB Sn
00000140: 616b 6580 0000 0000 0000 0100 2d42 dec7 ake.........-B..
这里我使用 bashism 将0x100转换成十进制256。
-s开关指示起始位置;-l表示要读取多少字节 。字节数应该等于头的大小;所以从0x0150减去0x100的开始。
By the way …
一些工具和文献使用八位字节而不是字节,因为一个八位字节 ( 八位字节,拉丁语意为八)是 8 位,相当于今天的一个字节*——但是在过去,一个字节的位数是可变的。*
*如果你仔细看,你可以辨认出一些 ASCII 字符——这就是标题。从偏移量0x130开始从左向右计数,得到0x134,这是 pandocs 文档中的墨盒名称。
问问你自己,当标题在0x014F处“结束”时,我为什么要使用0x0150。
这就是如何从 hexdump 中手动读取盒式磁带头的方法。信息丰富,但它没有推进我们的模拟器项目,所以让我们编写一个简单的盒式元数据读取器。
使用struct模块解包二进制数据
大多数语言都有某种符号来表示类型的数据集合。在 C 中是struct。在 Pascal 里是record。这是一种有效的组织信息的方式,尤其是当你可以命令编译器(如果有的话)以这样一种方式打包该结构时,你可以完全控制该结构的布局,一位一位地,在内存和磁盘上。当您想要表示字节集合时,这是一个很有用的属性,就像我们需要处理盒式磁带的头元数据一样。
在 Python 中,你可以用无数种方式来做到这一点。然而,问题是,像这样的二进制结构需要有精确的眼光:你不仅需要一个字节一个字节地读出信息,还需要考虑以下事项:
Endianness, or the direction in which you read a sequence of bytes
大端和小端系统对字节结构的解释不同。Z80 是大端 CPU,而你的可能是小端 CPU。
在你的 Python 解释器中输入sys.byteorder来确定。
Signed vs Unsigned integers
无符号整数仅是正整数。另一方面,Signed 既是否定的,也是肯定的。您选择的表示将决定字节字符串中保存的值。
Strings
是 C 风格的字符串还是 Pascal 风格的?前者用一个 NUL 字符结束一个字符串,以表示到达了结尾。但是 Pascal 字符串会在它们的前面加上字符串的字节大小。
Size
你读的是 8 位数还是 16 位数?也许是更大的?
这样的例子不胜枚举。换句话说,组成我们数据的比特和字节是一个表示的问题。如果弄错了,你会读到垃圾,或者更糟的是,它只适用于某些值,而不适用于其他值!
幸运的是,Python 附带的struct模块能够处理所有这些问题。使用一种小型语言,就像您用于格式化字符串的语言一样,您可以告诉 Python 如何解释二进制数据流。
大小端序
先简单说一下字节序以及它是什么。它在我们如何阅读和表达信息方面起着重要的作用。这是你读取数据字节的序列的顺序。
一个从《格列佛游记》这本书里借来的名词,到处都是。
因此,考虑以下 Python 中的十六进制字符串:
>>> data = bytes.fromhex('AB CD') >>> data b'\xab\xcd'
当那个字节串用表示为小端或大端时,十进制值会改变。回想一下,此时它只是一个字节串;它还没有任何意义。这意味着如果你不知道写它的人选择的是大端还是小端,十六进制字符串AB CD的数值就是不明确的!
考虑之前的变量data:
>>> little = int.from_bytes(data, 'little') >>> big = int.from_bytes(data, 'big') >>> (little, big) (52651, 43981) >>> (hex(little), hex(big)) ('0xcdab', '0xabcd')
这是因为两种字节序格式的数据方向不同。小端解释为CD AB,大端解释为AB CD。
现在你可能想知道为什么是CD AB而不是DC BA——也就是说,为什么边界是一个字节而不是半个字节?
总之,大多数 CPU(至少)是 8 位可寻址的,这意味着地址总线将读取和写入至少 8 位(或 1 个字节)的数据。Game Boy 有一个 8 位 CPU ,但有 16 位可寻址总线,因此它运行的最小单位是 1 字节。
奇怪的 CPU 平台可能会有所不同,50 年前很多都是如此,但就我们而言,今天的 CPU 运行在 8 位的倍数上。
为了进行演示,您可以将任何十进制数转换为以大端或小端顺序填充到给定长度的字节字符串。在这里,我使用十六进制符号来匹配前面的示例字节字符串中的一个字节(关键字length)。
>>> int.to_bytes(0xCD, length=1, byteorder='little') b'\xcd'
>>> int.to_bytes(0xCD, length=1, byteorder='big') b'\xcd'
如您所见,没有发生字节置换。原因是这样的:由于我们操作的最小单位是 8 位,所以无论是从左到右还是从右到左阅读都没有区别;0xCD这个词就是0xCD而已。现在,拥有位级(相对于字节级)字节序是完全可能的,在这种情况下,你读取位的顺序是变化的。但这里的情况并非如此。
现在再一次,但是大小为 2(即 16 位):
>>> int.to_bytes(0xCD, length=2, byteorder='big') b'\x00\xcd'
>>> int.to_bytes(0xCD, length=2, byteorder='little') b'\xcd\x00'
现在,它确实用 Python 转置了(按照之前的规则)额外的字节,用小端字节序中的0x00填充,以确保系统正确读取 2 个字节的小端排序数据。
在大端和小端之间转换
正如上面的例子所展示的,您可以让 Python 来完成在 big 和 little endian 之间转换的艰苦工作。但是您也可以通过移位来手动交换它们:
Converting a 16-bit value between big and little endian with bit shifting
我现在还不会过多地讨论这个方法;请放心,稍后当我们开始执行 Z80 的指令时,菜单上会有一点无聊。
>>> value = 0xABCD >>> hex(((value & 0xFF00) >> 8) | (value & 0xFF) << 8) '0xcdab'
当然,这种方法也适用于大于 16 位的值,只需稍加修改。
Converting arbitrary values between big and little endian with int
该方法将任意整数转换为给定字节顺序的字节字符串–little或big。
>>> 0xC0FFEE.to_bytes(length=3, byteorder='big') b'\xc0\xff\xee' >>> >>> int.to_bytes(0xC0FFEE, length=3, byteorder='little') b'\xee\xff\xc0'
因为整数是 Python 中的对象,所以它们带有各种各样的方法,您可以直接对它们进行调用。我敦促你抵制用文字值来做这件事的诱惑,而使用int。它更容易阅读。
Using the array module
array模块是 Python 附带的一个基本数组实现。你给它一个大小初始化器(下一节将详细介绍它们的含义)——有点像 numpy 中的dtype——Python 处理剩下的事情。如果你有一个数组充满了你想要交换的值,这个方法很有用。
>>> a = array.array('H', b'\xAB\xCD\x00\x01') >>> a array('H', [52651, 256]) >>> a.byteswap() >>> a array('H', [43981, 1])
字节字符串和类型表示
首先,您需要收集 Pandocs 中的墨盒标题部分中表示的所有字段,并将它们分别映射到您在结构格式字符中看到的字段。
一旦理解了基础知识,将它们映射到字段并不困难。不过,要记住的主要一点是,我们只对字节进行操作,就像这样:
>>> b'Inspired Python' b'Inspired Python'
字节字符串在这里很重要,因为不会发生与计算机的区域设置的相互转换;它只是原始形式,没有经过任何到UTF-8或其他字符编码的转换。
考虑一下这个字节串,里面有一堆转义编码的东西:
>>> b'\xf0\x9f\x90\x8d' b'\xf0\x9f\x90\x8d'
>>> b'\xf0\x9f\x90\x8d'.decode('utf-8') ' '
当我将它从字节格式解码成UTF-8时,我得到了……一条蛇。所以字节串只是一段原始的字节。它可以有任何意义,直到我们赋予它目的:将其转换为 UTF-8 产生一条蛇,但是如果我使用struct.unpack_from,我可以告诉 Python 它必须将其表示为一个无符号整数*:*
>>> struct.unpack_from('I', b'\xf0\x9f\x90\x8d') (2375065584,)
这就是我们需要对盒式磁带头做什么的关键。我们需要想出一系列格式字符串给unpack_from,这样它才能发挥它的魔力。
幸运的是,我们只需要几个不同的:
| 格式字符串 | “C”等效型 | 目的 |
| ??x | 填充字节 | 跳过一个字节或填充另一个格式字符串。对我们不关心的东西有用。 |
| ??= | 使用系统的原生字节序格式 | 大概就是你想要的。Python 将决定在读取数据时应该使用小端还是大端 |
| >,< | 大&小端指标,分别为 | 非常重要。Z80 以大端顺序存储东西,所以如果我们的系统是小端顺序,我们应该告诉它用小端顺序来表示。 注意:必须是格式字符串的第一个字符。 |
| ??s | 字符数组 | 适用于任意长度的文本。 带前缀表示长度,像10s。 |
| ??H | 无符号短整型 | 2 字节无符号整数 |
| ??B | 无符号字符 | 用作 1 字节无符号整数 |
因此,要使用它,您可以将格式字符串组合成一系列解包指令。考虑这个简单的例子,它提取了几个数字——以大端字节序——和一个字符串:
>>> struct.unpack_from('>BB5sH', b'\x01\x02HELLO\x03\x04') (1, 2, b'HELLO', 772)
密切关注>。尝试用<运行代码,然后用=再次运行。
要记住的关键是:
You want to convert to your platform’s native endian format
我的意思是,你没有时间去做,但是你必须在精神上和程序上一直交换东西。不好玩。
在我们的例子中,Z80 是大端字节序,所以如果你的平台也是小端字节序,你应该把它转换成小端字节序**。如果是 big endian,就不需要转换或者改变什么。**
Knowing the byte order is critical
如果你不知道二进制文件格式的字节顺序,你就有点麻烦了。您可以尝试通过寻找格式类型编码的迹象来逆向工程可能的字节顺序,如二进制补码、浮点、ASCII 字符串,但这是一个漫长的过程。
记住这一点,让我们继续使用盒式磁带阅读器。
Game Boy 盒式元数据阅读器
FIELDS = [ (None, "="), # "Native" endian. (None, 'xxxx'), # 0x100-0x103 (entrypoint) (None, '48x'), # 0x104-0x133 (nintendo logo) ("title", '15s'), # 0x134-0x142 (cartridge title) (0x143 is shared with the cgb flag) ("cgb", 'B'), # 0x143 (cgb flag) ("new_licensee_code", 'H'), # 0x144-0x145 (new licensee code) ("sgb", 'B'), # 0x146 (sgb `flag) ("cartridge_type", 'B'), # 0x147 (cartridge type) ("rom_size", 'B'), # 0x148 (ROM size) ("ram_size", 'B'), # 0x149 (RAM size) ("destination_code", 'B'), # 0x14A (destination code) ("old_licensee_code", 'B'), # 0x14B (old licensee code) ("mask_rom_version", 'B'), # 0x14C (mask rom version) ("header_checksum", 'B'), # 0x14D (header checksum) ("global_checksum", 'H'), # 0x14E-0x14F (global checksum) ]
struct.unpack_from的格式字符串必须是连续的,因为它不支持换行符和注释。为了解决这个问题,并使原本混乱的字母汤变得更加清晰,我建立了一个元组列表,每个元组包含未来的属性,我希望以后引用这个值。如果它是None,它表明我根本不想存储这个值。
至此,盒式元数据差不多完成了——不管怎样,这是最难的部分。现在,在我们深入研究进行实际读取的代码之前,让我们使用假设编写一个快速测试。
假设使用巧妙的算法来生成测试数据,尝试破解您的代码。太棒了。你可以在这里 阅读更多关于 基于属性的假设测试。
import sys import hypothesis.strategies as st from hypothesis import given HEADER_START = 0x100 HEADER_END = 0x14F # Header size as measured from the last element to the first + 1 HEADER_SIZE = (HEADER_END - HEADER_START) + 1 @given(data=st.binary(min_size=HEADER_SIZE + HEADER_START, max_size=HEADER_SIZE + HEADER_START)) def test_read_cartridge_metadata_smoketest(data): def read(offset, count=1): return data[offset: offset + count + 1] metadata = read_cartridge_metadata(data) assert metadata.title == read(0x134, 14) checksum = read(0x14E, 2) # The checksum is in _big endian_ -- so we need to tell Python to # read it back in properly! assert metadata.global_checksum == int.from_bytes(checksum, sys.byteorder)
这里有一点要解开,让我们从顶部开始。我定义了一些在测试中使用的常量。现在,您已经知道了卡片头的开始和结束值:它们和其他卡片头元数据FIELDS一起取自 pandocs。
测试本身使用假设来生成一个min_size和max_size的随机二进制垃圾分类,等于报头的大小加上其偏移量。虽然我可以很容易地用-0x100来抵消一切,但是我喜欢这个想法,我也在测试我们可以从正确的偏移量读取。
测试本身的特点是read(),一个助手函数从offset中读取count个字节。注意,我们需要添加+1,因为如果offset = count = 1那么data[1:1] == ''。
read_cartridge_metadata调用定制代码来读取元数据——下面将详细介绍——并检查它是否读取了一些字段。我选择了标题,因为它是一个字符串,选择了全局校验和,因为它是一个两字节的字段,因此正确的字节顺序很重要。
最终检查确保我们读入校验和,就好像它是 big endian 一样。
现在对于盒式磁带阅读器本身:
CARTRIDGE_HEADER = "".join(format_type for _, format_type in FIELDS) CartridgeMetadata = namedtuple( "CartridgeMetadata", [field_name for field_name, _ in FIELDS if field_name is not None], ) def read_cartridge_metadata(buffer, offset: int = 0x100): """ Unpacks the cartridge metadata from `buffer` at `offset` and returns a `CartridgeMetadata` object. """ data = struct.unpack_from(CARTRIDGE_HEADER, buffer, offset=offset) return CartridgeMetadata._make(data)
没错。就是这样。CARTRIDGE_HEADER从FIELDS中取出每个元组中的键,而CartridgeMetadata是我们将每个field_name映射到的namedtuple,也就是而不是??。
struct.unpack_from函数完成了大部分繁重的工作。它需要一个可选的offset,我们默认为0x100的通常位置。解包后的值元组被直接输入到CartridgeMetadata._make中,后者将整个事情转换成一种更易于访问的格式:
>>> p = Path('snake.gb') >>> read_cartridge_metadata(p.read_bytes()) CartridgeMetadata( title=b"Yvar's GB Snake", cgb=128, new_licensee_code=0, sgb=0, cartridge_type=0, rom_size=0, ram_size=0, destination_code=1, old_licensee_code=0, mask_rom_version=45, header_checksum=66, global_checksum=51166, )
这就是盒式磁带元数据读取器。
Endianness is important
但前提是你一次代表的多于一个字节的。Z80 CPU 是 Big Endian,所以在读取值时要记住这一点。如果你使用的是小端 CPU ( sys.byteorder告诉你是哪个),那么这就是你应该要求的!
All the pieces matter
插件元数据在我们的模拟器中有一些用处,但它也是一个很好的教程,可以测试和提高您对底层结构的了解,比如事物的二进制表示。它以后会派上用场的,这是一个很好的简单的方法来缓解你的方式。
Python can easily represent, and convert between, the representations we’ll need for the emulator
十六进制、大端和小端、二进制和任何数量的结构化二进制格式都是可能的,这要归功于许多公认隐藏的方法调用。
Z80 指令解码器和反汇编程序
短暂但重要的插曲。
在整个课程中,我将 CPU 称为 Z80(或 Z80 风格),因为它与 Game Boy 中的 CPU 类似。但它并不完全相同:它是一款类似英特尔 8080 的夏普 CPU,名为 LR35902 。我将使用 Z80 这个术语,尽管它不是 100%真实的。原因是除了提到 Game Boy 之外,互联网上关于 Sharp CPU 的文档很少。如果你想发现更多关于 CPU 的文献,你最好的选择是搜索 Z80,因为它是一种非常常见的 CPU 型号。请记住,操作码和其他一些 CPU 细节确实不同。
了解了字节序列的表示如何依赖于上下文之后,现在让我们把注意力转向反汇编器。
在我继续之前有一个要点。CPU 仿真器实际上根本不需要反汇编器;但是你会。CPU 只关心从字节流中解码指令,而不关心在屏幕上显示给人们阅读。但是,良好的调试和仪器设备对于成功的仿真器项目是至关重要的。最好从反汇编程序(和解码器)开始,因为你想了解 CPU 将要模拟的指令,以及为什么。
在 Game Boy 模拟器简介 中,我们解析了操作码文件,并且有一个可选任务来打印操作码。下一步我们将需要这些经过解析的操作码字典。我选择了数据类;它们看起来有点像这样:
Instruction( opcode=0x0, immediate=True, operands=[], cycles=[4], bytes=1, mnemonic="NOP", comment="", )
我们需要两本说明书词典。一个用于前缀指令,另一个用于常规指令。有两个,因为不可能用一个字节来表示所有不同的指令。因此,前缀指令是,以0xCB作为的前缀,以向 CPU 指示之后的字节是前缀指令。
所以CB 26有SLA (HL)的助记符。你可以在 pandoc 上看到一个 CPU 指令集的列表,当然,也可以在你解析过的字典中看到。我还建议你随身携带游戏机 CPU 手册,因为它对使用说明有更详细的解释。
现在我们有了操作码列表,接下来就是将字节流映射到它们的操作码等价物的问题了。然而,有几个障碍使得使用我们上面使用的struct方法不可行:
The byte lengths of the instructions are not fixed
每个指令的大小从一个字节到两个字节不等。所有带前缀的指令本质上都是两个字节长。
Opcodes are variadic
有些操作码有操作数,有些没有。例如,0x0 ( NOP)没有操作数。但是CB 26有一个。有些还引用一个特殊的内存位置,进一步增加了要读取的字节数。
The offset you read from is unknown
也许你正在从0x0开始读,或者也许是另一个偏移。
The stream is potentially infinite
当我们反汇编盒式 ROM(它有固定的大小)时,这种情况不会发生,但是一旦我们的仿真器开始执行指令,这种情况就会发生,而且我们没有简单的方法知道,或者 。
By the way …
这就是所谓的停顿问题。
因此,使用解析的操作码作为我们需要读取的内容的指南,更容易获取我们所学的知识,一次读取一个字节的数据。
所以目标大致是:
-
给定一个地址(想想字节数组中的索引)和我们解析过的操作码,读取一个字节并将地址加 1
-
如果字节等于
0xCB,使用前缀指令操作码查找表,并将地址递增 1。 -
从操作码查找表中获取指令
-
在指令的操作数上循环,并且:
-
如果操作数有
bytes > 0,读取同样多的字节,将地址递增,并将其存储为操作数的value。 -
如果是
bytes is None字段,那么操作数不是数据值,而是固定操作数,所以将其存储在name中。
-
-
此时,您将拥有一条指令和相关联的操作数(如果有的话)。返回地址和说明。
-
确保您读取的任何值都被转换为系统的 byteorder。使用
sys.byteorder
这个练习的目的是将字节串翻译成 CPU 和我们这些开发者都能理解的高级指令。因为字节长度根据操作码而变化,所以我们不能简单地将流分成指令包来解析。
让我们从测试NOP指令开始:
@pytest.fixture def make_decoder(request): def make(data: bytes, address: int = 0): opcode_file = Path(request.config.rootdir) / "etc/opcodes.json" return Decoder.create(opcode_file=opcode_file, data=data, address=address) return make def test_decoder_nop_instruction(make_decoder): decoder = make_decoder(data=bytes.fromhex("00")) new_address, instruction = decoder.decode(0x0) assert new_address == 0x1 assert instruction == Instruction( opcode=0x0, immediate=True, operands=[], cycles=[4], bytes=1, mnemonic="NOP", comment="", )
在这里,我使用 pytest 工厂夹具来生成Decoder对象,它将完成所有繁重的工作。然后,测试生成一个带字节串\x00的解码器。接下来,我要求解码器解码地址0x0(这当然是我们的字节串中的第一个也是唯一一个字节),并断言该指令与我从我解析的操作码文件中获得的指令相匹配,并且解码器返回的地址反映了新的位置:0x1。
现在是解码器。让我们从构造函数和类的框架开始。
@dataclass class Decoder: data: bytes address: int prefixed_instructions: dict instructions: dict @classmethod def create(cls, opcode_file: Path, data: bytes, address: int = 0): # Loads the opcodes from the opcode file prefixed, regular = load_opcodes(opcode_file) return cls( prefixed_instructions=prefixed, instructions=regular, data=data, address=address, )
解码器要求data解码。稍后,我们将使用仿真器的内存库替换“数据”的一般概念。目前,一般的字节字符串是一个不错的替代品。
我们还封装了一个address,这样我们以后就可以查询它最后的位置。现在还不需要,但是有它在身边很有用。最后,有两个包含已解析操作码的字典。
create classmethod 是一个工厂,它读入操作码文件并调用解析 JSON 操作码文件的load_opcodes(未显示)。它还需要另外两个参数来为解码器提供数据和起始地址。
撇开随机不谈:我建议你不要把有副作用的代码塞进__init__构造函数中,因为这几乎总是一股代码味。如果创建或与其他事物对话是类的契约的一部分,你应该把它放到一个@classmethod中,它会为你做这件事,就像我在这里做的那样。
现在,您可以直接创建一个Decoder的实例,并传递伪造的字典值,而不必像在__init__中那样修补或切换load_opcodes调用。
现在是这节课的重点。解码器方法本身。
import sys @dataclass class Decoder: # ... Decoder continued ... def read(self, address: int, count: int = 1): """ Reads `count` bytes starting from `address`. """ if 0 <= address + count <= len(self.data): v = self.data[address : address + count] return int.from_bytes(v, sys.byteorder) else: raise IndexError(f'{address=}+{count=} is out of range') def decode(self, address: int): """ Decodes the instruction at `address`. """ opcode = None decoded_instruction = None opcode = self.read(address) address += 1 # 0xCB is a special prefix instruction. Read from # prefixed_instructions instead and increment address. if opcode == 0xCB: opcode = self.read(address) address += 1 instruction = self.prefixed_instructions[opcode] else: instruction = self.instructions[opcode] new_operands = [] for operand in instruction.operands: if operand.bytes is not None: value = self.read(address, operand.bytes) address += operand.bytes new_operands.append(operand.copy(value)) else: # No bytes; that means it's not a memory address new_operands.append(operand) decoded_instruction = instruction.copy(operands=new_operands) return address, decoded_instruction
我认为read方法不言自明。如果我们试图读取超出字节字符串的界限,引发一个IndexError,否则从address返回count字节数。
decode方法遵循我在上面提出的算法。我们一次读取一个字节,记住当我们这样做时递增address,如果有与匹配指令相关联的操作数,我们读取一个额外的operand.bytes(再次递增地址)并将其存储在operand.value中。如果operand.bytes is None我们只是按原样存储操作数。
进行bytes is not None检查的原因与 JSON 文件中操作码表的布局有关。并非所有操作数都是参数化的,需要额外的字节来读取。如果他们没有字节要读,我们仍然想要操作数。
两个指令字典都包含我在 指令和操作数数据类 中定义的Instruction数据类的实例。唯一需要注意的是返回Instruction或Operand实例的相同副本的copy方法,但是交换出了value(用于Operand)或operands(用于Instruction)。
我还为Operand和Instruction类添加了一些漂亮的打印机:
@dataclass class Operand: # ... etc ... def print(self): if self.adjust is None: adjust = "" else: adjust = self.adjust if self.value is not None: if self.bytes is not None: val = hex(self.value) else: val = self.value v = val else: v = self.name v = v + adjust if self.immediate: return v return f'({v})' @dataclass class Instruction: # ... etc ... def print(self): ops = ', '.join(op.print() for op in self.operands) s = f"{self.mnemonic:<8} {ops}" if self.comment: s = s + f" ; {self.comment:<10}" return s
打印机代码不言自明。目标是格式化一条指令(和任何操作数)看起来像手写的汇编代码。它有一种风格,你可以看到它在所有的 Game Boy 和 Z80 汇编语言手册中或多或少是相同的。
有了漂亮的打印机和可用的解码器,我们就快完成了:
>>> dec = Decoder.create(opcode_file=opcode_file, data=Path('bin/snake.gb').read_bytes(), address=0) >>> _, instruction = dec.decode(0x201) >>> instruction Instruction(opcode=224, immediate=False, operands=[ Operand(immediate=False, name='a8', bytes=1, value=139, adjust=None), Operand(immediate=True, name='A', bytes=None, value=None, adjust=None) ], cycles=[12], bytes=2, mnemonic='LDH', comment='') >>> instruction.print() 'LDH (0x8b), A'
现在很容易将其推广到能够分解任意长度字节的函数:
def disassemble(decoder: Decoder, address: int, count: int): for _ in range(count): try: new_address, instruction = decoder.decode(address) pp = instruction.print() print(f'{address:>04X} {pp}') address = new_address except IndexError as e: print('ERROR - {e!s}') break
当以偏移量0x150(恰好是snake.gb的入口点)运行时:
>>> disassemble(dec, 0x150, 16) 0150 NOP 0151 DI 0152 LD SP, 0xfffe 0155 LD B, 0x80 0157 LD C, 0x0 0159 LDH A, (0x44) 015B CP 0x90 015D JR NZ, 0xfa 015F DEC C 0160 LD A, C 0161 LDH (0x42), A 0163 DEC B 0164 JR NZ, 0xf3 0166 XOR A 0167 LDH (0x40), A 0169 LD A, 0x0
仅此而已。正在工作的拆卸器。像 Ghidra 和 IDA Pro 这样的高级工具附带了一系列附加功能,比如计算调用图、函数开始和结束的位置等等。但是这足以让我们开始理解我们未来的仿真器 CPU 正在执行什么。
我们现在准备处理等式的下一部分:编写构成 CPU 的框架;CPU 寄存器(以及它们是什么);和一个 Z80 汇编语言速成班来帮助我们入门。
摘要
Representation is a matter of interpretation
大小端是需要注意的一件事。另一个原因是,一系列连续的位和字节可以表示不同的意思。我们只是触及了表面。后来,有符号数和无符号数的概念以及如何表示它们又出现了。
Disassemblers are key to CPU emulation
如果您以前从未做过系统编程,那么编写反汇编程序的想法可能看起来很困难或具有挑战性:如果您必须对操作码和操作数进行逆向工程,它们肯定会很困难!我们已经得到了很大的帮助,因为有人已经仔细地将操作码和操作数转录成可解析的 JSON。没有它,我们将不得不首先做那些乏味的手工工作。
但是即使漂亮的反汇编对我们开发人员来说是有用的,CPU 仍然需要经历一个“获取-解码-执行”的循环。目前,我们已经简化了获取,因为它还没有从内存中读取。但是解码器是完整的,它将作为仿真器前进的基石。*
Python 模式匹配示例:使用路径和文件
Author Mickey Petersen
操作文件和路径字符串是枯燥的工作。这是一种常见的活动,特别是在数据科学中,文件结构可能包含重要的语义线索,如日期或数据源。通常通过混合使用if语句和自由使用 pathlib 的Path或os.path来实现信息的上下文化,但是 Python 3.10 中的结构模式匹配特性可以减少繁琐。
考虑一个看起来有点像这样的目录结构:
cpi/<country>/by-month/<yyyy-mm-dd>/<filename>.<ext>
cpi/<country>/by-quarter/<yyyy-qq>/<filename>.<ext>
其中cpi表示居民消费价格指数;country是一个国家的 ISO-3166 编码;yyyy-mm-dd是特定月份的 ISO 日期;yyyy-qq是年份和季度;而filename是任意文件名,ext是扩展名。
通常,您只需分割路径,编写一些快速逻辑来挑选您需要的内容,这对于简单的事情来说会很好,但是如果您必须处理文件路径中几十个可变的字段,这种方法将无法扩展。因此,让我们来看一种使用match和case关键字进行扩展的方法。
按国家分发给正确的读者
首先要考虑的是——这只是一个例子——将解析文件路径的逻辑和处理文件的逻辑分开。绝大多数“结构化”数据,如 CPI 指数,因生成它们的机构不同而有很大差异——而且事实的来源很可能不止一个。所以在上面的例子中,country字段是我们不能希望消失或假装在任何地方都可以工作的东西。
让我们充实几个实现后者的框架函数。我不会讨论假设的解析本身,但是 Python 模式匹配示例:ETL 和 Dataclasses 展示了一个示例,向您展示如何做到这一点。
from pathlib import Path import datetime def read_cpi_series_by_month( country_code: str, filepath: Path, observation_date: datetime.date ): match country_code: case "GB": if observation_date < datetime.date(year=2000, month=1, day=1): return read_legacy_uk_cpi_series(filepath, observation_date) return read_uk_cpi_series(filepath, observation_date) case "NO" | "SE" | "DK" | "FI": return read_nordic_cpi_series(filepath, observation_date) # ... etc ... case _: raise ValueError(f"There is no valid CPI Series read for {country_code}")
该控制器函数将 a country_code作为输入;一个filepath给底层数据;还有一个observation_date。我添加了几个例子来演示这样一个控制器是什么样子的。此时,我对文件逻辑不感兴趣。在我担心这个问题之前,考虑一下应用程序的核心是值得的。这里有几个要点:
Reading a time series file is a product of the country and the observation date
有可能(嗯,现实生活中铁定的事!)数据格式会随着时间而变化。其他复杂因素可能包括根据文件名或扩展名的一部分确定正确的阅读器——但稍后会详细说明——因此这也有空间。
Combining rules makes it easier to understand what is going on
一些国家可能共享相同的数据格式,所以我也可以将它们合并到一个case语句中,以节省未来开发人员可能遇到的“认知负荷”。因此,添加或删除国家也非常容易。
I can still use if statements when it makes sense to do so
我可以通过将if语句放入case语句本身来使if语句成为守卫*。我选择不这样做,但是对于复杂的规则,您可能希望这样做,特别是如果您有许多相似但只是略有不同的规则。*
Fail immediately if there is no valid reader
为了简洁起见,我使用了ValueError,但是在实际的应用程序中,自定义异常会更好。
这样,控制器就会读取文件的内容。现在让我们向上移动一层,考虑如何从我们假设的目录结构中获取信息。
匹配目录和文件路径
现在,不幸的是,模式匹配引擎不支持复杂的字符串内模式匹配,比如正则表达式,所以我们必须想出另一种方法来给模式匹配引擎提供结构化的数据。
最明显的两种方法是os.path.split()和pathlib.Path。我更喜欢后者(更多信息见 通用路径模式 ),因为它更容易推理。
Path类可以将文件路径分割成组成完整文件路径的组成部分:
>>> Path('cpi/DK/by-month/2007-08-01/ts_cpi_by_month.xlsx').parts ('cpi', 'DK', 'by-month', '2007-08-01', 'ts_cpi_by_month.xlsx')
在我看来,这是一个非常有用的模式匹配结构。
import re def parse_ts_structure(filepath: str | Path): structure = Path(filepath).parts match structure: case ("cpi", country_code, "by-month", date, filename) if ( len(country_code) == 2 and re.match(r"^\d{4}-\d{2}-\d{2}$", date) ): observation_date = parse_date(date) read_cpi_series_by_month(country_code, filepath, observation_date) case ("cpi", country_code, "by-quarter", date, filename) if ( len(country_code) == 2 and re.match(r"^\d{4}-Q\d$", date) ): observation_date = parse_quarter_date(date) read_cpi_series_by_quarter(...) case _: raise ValueError(f"Cannot match {structure}")
该函数接受一个字符串或Path并将它转换成一个由部分组成的元组,看起来应该有点像这样:
(<data source>, <iso country>, <frequency>, <observation date>, <filename>)
在每条case语句中,我都与"cpi"进行文字匹配,因为这是我们(目前)支持的唯一数据源,但是很容易想象在实际应用程序中这个列表会变得很长。
与前面的例子不同,我添加了守卫而不是常规的if语句,这是有原因的:
I am guarding the pattern I want to match against to ensure it has the basic structure I expect
两次检查中的每一次都只验证了结构是我表面上想要的:
-
对于一个国家来说,
country_code必须是一个两位数的 ISO 代码,但是我不关心在那个时间点上它是否是一个合法的国家; -
并且,我使用一个快速的正则表达式来确保日期结构看起来像一个 ISO 日期。再次注意,我是而不是检查日期是否有效——只是检查它是否符合规定的
YYYY-MM-DD(或YYYY-QN)格式。
因此,我可以在每个case块中生成它们的if语句,但是如果两个检查中的任何一个失败了,我就必须抛出异常。我现在可以——尽管为了简洁起见我没有——检查一下通过检查的country_code实际上是否是一个真实的国家。日期也是一样:9999-99-99会通过守卫,但不会通过parse_date函数。
摘要
Pattern Matching is useful even for mundane activities
处理文件和路径太常见了,模式匹配可以减少不可避免地出现的永无止境的语句
A lot of problems are simpler if you find a commonality or shared structure to them
这里的问题是一个目录结构,在目录名中有很多上下文,但它可以是任何内容。回想一下,是Path(...).parts把一个普通的字符串变成了一个计算机(和人类!)很容易就能推理出大概。
Python 模式匹配示例:ETL 和 Dataclasses
Author Mickey Petersen
在 掌握结构模式匹配 中,我向您介绍了结构模式匹配的理论,所以现在是时候应用这些知识并构建一些实用的东西了。
假设您需要将数据从一个系统(基于 JSON 的 REST API)处理到另一个系统(用于 Excel 的 CSV 文件)。一个共同的任务。提取、转换和加载(ETL)数据是 Python 做得特别好的事情之一,通过模式匹配,您可以简化和组织业务逻辑,使其保持可维护性和可理解性。
让我们得到一些测试数据。为此你需要requests库。
>>> resp = requests.get('https://demo.inspiredpython.com/invoices/') >>> assert resp.ok >>> data = resp.json() >>> data[0] {'recipient': {'company': 'Trommler', 'address': 'Annette-Döring-Allee 5\n01231 Grafenau', 'country_code': 'DE'}, 'invoice_id': 15134, 'currency': 'JPY', 'amount': 945.57, 'sku': 'PROPANE-ACCESSORIES'}
目标
数据——可以随意使用上例中提供的演示 URL 是我们销售丙烷(和丙烷配件)的虚构公司的发票列表。)
作为任何严肃的 ETL 过程的一部分,您必须考虑数据的质量。为此,我想标记可能需要人工干预的条目:
-
查找不匹配的支付货币和国家代码。例如,上面的例子将支付货币列为
JPY,但是国家代码是德国。 -
确保发票 id 是唯一的,并且它们都是小于
50000的整数。 -
将每张发票映射到一个专用的
Invoice数据类,并将每个发票接收人映射到一个Company数据类。
然后,
-
将质量保证发票写入 CSV 文件。
-
所有未通过测试的内容都会被标记出来,并放入不同的 CSV 中进行人工审查。
不过有一点很重要。
在实际的应用程序中,会有一个验证层来检查输入数据是否有明显的数据错误,比如字符串字段中的整数,或者缺失的字段。为了简洁起见,我将不包括这一部分,但是您应该使用类似于marshmallow或pydantic的包来正式化您(消费者)与您与之交互的数据生产者之间的契约,以捕捉(并处理)这些错误。
但是,为了便于讨论,让我们假设输入数据满足这些基本标准。但是验证国家代码和货币是否正确并不是像marshmallow这样的图书馆的工作。
获取 API 数据
让我们从我之前提取的数据开始:
import requests def get_invoices(url): response = requests.get(url) # Raise if the request fails for any reason. response.raise_for_status() return response.json()
在这里,如果响应是除了来自服务器的200 OK之外的任何东西,我让请求引发一个异常。我还天真地假设响应体是 JSON,因为这只是一个演示。
定义数据类
现在让我们定义数据类。两个就足够了:一个Company数据类,用于保存发票接受者的详细信息;和一个Invoice数据类,它将引用接收公司和发票细节本身:
from dataclasses import dataclass from typing import Optional @dataclass class Company: company: str address: str country_code: str @dataclass class Invoice: invoice_id: int currency: str amount: float sku: str recipient: Optional[Company]
一旦数据从其源格式转换后,每个数据类都是公司或发票的规范表示。
分离你的顾虑
为了帮助测试,我想做的一件事是将公司的处理与发票的处理分开:
1def process_raw_records(records): 2 invoices = [] 3 for record in records: 4 match record: 5 case {"recipient": raw_recipient, **raw_invoice}: 6 recipient = process_raw_recipient(raw_recipient) 7 invoice = process_raw_invoice(raw_invoice) 8 invoice.recipient = recipient 9 invoices.append(invoice) 10 case _: 11 raise ValueError(f"Cannot parse structure {record}") 12 return invoices
该函数循环遍历records中的每个原始记录。对于每个record,它会尝试将record的结构与你在第一个case语句中看到的声明模式进行匹配。我写的模式有点分散,所以让我解释一下为什么它看起来是这样的。
我想拆分发票和收款人的处理。为了做到这一点,我声明了一个模式,该模式必须至少有个键"recipient"和其他所有东西——如果有是其他任何东西的话——到**raw_invoice中。如果模式与record不匹配,它当然会被跳过;在这种情况下,默认模式_被触发,引发异常。**
回想一下,**something是 Python 中的关键字符号,通常是将字典扩展成key=value对,用于函数调用或字典内部。这里它的意思正好相反:收集键-值对,并将它们存储在字典something中。
模式匹配引擎足够聪明,能够理解这种符号,它巧妙地将逻辑分离开来,以确定哪些内容应该放在哪个函数中。这有几个好处:
Separation of Concerns and Ease of Testability
我可以将process_raw_recipient、process_raw_invoice和process_raw_records作为一个整体进行测试,也可以单独进行测试,以诱导各种测试场景,而不必笨拙地尝试并得出一个与我在测试中预期的行为集相匹配的records列表。
Each function is standalone and can be used for other things
您可以分别调用和解析发票和收款人。假设您有另一个名为/companies/的 API 端点,您希望将发票接受者与之相关联。现在您可以单独提取数据并无缝重用process_raw_recipient函数。
现在,让我们来看看每个处理器。
def process_raw_recipient(raw_recipient): match raw_recipient: case {"company": company, "address": address, "country_code": country_code}: return Company(company=company, address=address, country_code=country_code) case _: raise ValueError(f"Cannot parse invoice recipient {raw_recipient}") def process_raw_invoice(raw_invoice): match raw_invoice: case { "invoice_id": invoice_id, "currency": currency, "amount": amount, "sku": sku, }: return Invoice( invoice_id=invoice_id, currency=currency, amount=amount, sku=sku, recipient=None, ) case _: raise ValueError(f"Cannot parse invoice {raw_invoice}")
这两个函数各自获取包含发票接受者或发票本身的原始字典。
每个相应的case语句代表我想要匹配的字典的声明形式。process_raw_recipient期待三把钥匙:"company"、"address"和"country_code"。
在process_raw_invoice中,情况是一样的,但是键不同,当然,虽然我在创建Company对象时特别设置了recipient=None。为什么?我不想让这个函数担心接收者或者它是如何创建的:
The process_raw_invoice function should only process invoices
就这个函数而言,有没有接收者都不关它的事。
我可以让它调用process_raw_recipient并分配我得到的Company实例,但是我会将发票记录的解析与公司记录的解析紧密耦合。
The process_raw_records function is the controller
也就是说,它负责遍历每个原始记录;确定它是什么;正确地组合出我们想要的最终形状。随着时间的推移,该功能很可能会处理更多的事情:汇款通知、采购订单等。
这样一来,基本的提取和大部分转换就完成了。运行代码也很好:
>>> for result in process_raw_records(get_invoices("https://demo.inspiredpython.com/invoices/")): print(result) Invoice(invoice_id=19757, currency='USD', amount=692.3, sku='PROPANE-ACCESSORIES', recipient=Company(company='Rosemann Freudenberger GmbH & Co. KGaA', address='Eberthweg 56\n30431 Artern', country_code='DE')) # ... etc ...
执行质量保证规则
现在剩下转换和加载的最后部分。前面我描述了一些业务规则,我希望实现这些规则来保证数据的质量。我可以只用字典来做这件事,这在这个例子中是没问题的,但是如果你自己在构建这样的东西,你可能要处理更复杂的数据。有几个简单的结构化对象,您可以在这些对象上添加属性和其他助手方法,这就容易多了。
幸运的是,使用数据类不会削弱我们使用模式匹配的能力。因此,让我们实施第一条业务规则:
查找不匹配的货币和国家代码
因此,假设我想标记某些国家代码和货币组合,以便人工审查,以防会计部门的某些人弄错了货币字段。这种情况比你想象的要多。
1def validate_currency(invoice: Invoice): 2 match invoice: 3 case Invoice(currency=currency, recipient=Company(country_code=country_code)): 4 match (currency, country_code): 5 case ("USD" | "GBP" | "EUR", _): 6 return True 7 case ("JPY", "JP"): 8 return True 9 case ("JPY", _): 10 return False 11 case _: 12 raise ValueError( 13 f"No validation rule matches {(currency, country_code)}" 14 ) 15 case _: 16 raise ValueError(f"Cannot parse structure {invoice}")
validate_currency函数获取一张发票,如果能够推断出货币是否有效,则返回True或False;或者ValueError如果出现一般错误。
By the way …
请记住,您在case语句中声明了一个模式。Python 为您解决了如何将主题与模式相匹配的问题。在这种情况下,Python 不会创建Invoice或Company的实例,而是询问它们的内部结构,以确定如何将它们与主题匹配。
Python 中模式匹配真正巧妙的地方在于能够像上面的代码一样从对象结构中挑选出属性。我只指定我想要模式匹配的东西,因为您可以嵌套结构 ,所以您可以自由地指定您的代码必须拥有的与它所需要的数据的完整“契约”。
对,所以如果有匹配——也就是说,我们传递一个Invoice对象,它的recipient属性中有一个Company——那么我们可以继续实际的验证例程。
对于两个绑定名称currency和country_code,我将它们做成一个元组,没有别的原因,只是为了让我们人类更容易理解代码的意图。我可以很容易地将它转换成字典或其他结构——但是元组很好且易于阅读。
case语句捕捉到了实际的业务规则,而且,我必须说,是以一种非常清晰易读的方式。我们一点一点来看。
case ("USD" | "GBP" | "EUR", _): return True
该规则匹配元组的currency部分是 "USD"、"GBP"或"EUR"之一的的任何元组。元组的第二部分country_code是_,表示通配符模式——这意味着它的值是什么并不重要。什么都有可能。
从我们虚构的企业的角度来看,这一规则意味着,如果你用这三种货币中的任何一种来给你的发票命名,那么收款人的国家是什么并不重要:许多跨国公司用这三种货币中的任何一种来给他们的发票命名,所以代码返回True表示它是有效的。
接下来的两条规则专门针对日元:
case ("JPY", "JP"): return True case ("JPY", _): return False
第一种说法是,如果你用日元支付给一家日本公司,那么这是明智的,因为日本公司可能更喜欢用自己的货币支付。然而,如果这是而不是的情况,第一个 case 语句匹配失败,第二个匹配任何带有通配符_的内容,然后返回False,表明验证检查失败。
语句按照您书写的顺序进行测试。首先检查最明确和具体的模式*,将更一般的“回退”案例放在最后。问问你自己,如果你颠倒上面两个case语句的顺序会发生什么?* *### 捕获重复的发票 id
第二个也是最后一个业务规则是检查重复的发票 id。另一个致命的问题是,如果你不小心的话,可能会造成全面的伤害。
MAX_INVOICE_ID = 50000 def validate_invoice_id(invoice: Invoice, known_invoice_ids): match invoice: case Invoice( invoice_id=int() as invoice_id ) if invoice_id <= MAX_INVOICE_ID and invoice_id not in known_invoice_ids: known_invoice_ids.add(invoice_id) return True case Invoice(invoice_id=_): return False case _: raise ValueError(f"Cannot parse structure {invoice}")
和前面的业务规则一样,我只匹配我关心的属性。这里是invoice_id。但是我也通过写int() as invoice_id断言命名绑定必须是整数。Python 将进行一些基本的类型检查,以确保它确实是一个整数,正如我们的业务规则所规定的那样。此外,我添加了一个守卫来检查发票 ID 是否小于我们能够支持的最大值,以及我们以前是否见过它。
我选择提供一组现有的已知发票 id。这是特别有用的,比如说,如果你有一个充满发票 id 的实时系统,你也想核对。
如果case语句匹配,我们通过将发票 ID 添加到已知 ID 集合中来记录发票 ID,并返回True。
如果规则失败了,但是仍然有一个名为invoice_id的属性,我们简单地返回False来标记它,以便以后由人来检查。
把所有的放在一起
import csv from dataclasses import asdict def retrieve_invoices(url, known_ids=None): if known_ids is None: known_ids = set() validated_invoices = [] flagged_invoices = [] for invoice in process_raw_records(get_invoices(url)): if not all( [validate_currency(invoice), validate_invoice_id(invoice, known_ids)] ): flagged_invoices.append(invoice) else: validated_invoices.append(invoice) return validated_invoices, flagged_invoices def store_invoices(invoices, csv_file): fieldnames = [ # Recipient Company "company", "address", "country_code", # Invoice "invoice_id", "currency", "amount", "sku", ] w = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore") w.writeheader() w.writerows( [{**asdict(invoice), **asdict(invoice.recipient)} for invoice in invoices] ) def main(): validated, flagged = process_invoices("https://demo.inspiredpython.com/invoices/") with open("validated.csv", "w") as f: store_invoices(validated, f) with open("flagged.csv", "w") as f: store_invoices(flagged, f)
剩下要做的就是把它们绑在一起。retrieve_invoices函数获取原始发票并调用我之前编写的处理器代码。它还应用业务规则,并基于这些检查的结果,将它们分成flagged_invoices或validated_invoices。
最后,它将发票存储到两个不同的 CSV 文件中。Python 的dataclasses模块附带了一个方便的asdict助手函数,它将类型化的属性从对象中取出,再次放入字典中,因此 CSV writer 模块知道如何存储数据。仅此而已。
摘要
Pattern Matching is a natural way of expressing the structure of data and extracting the information you want
正如这个演示项目向您展示的,很容易捕获与数据结构相关的业务规则,同时从中提取您需要的信息。添加或修改规则也很容易。
Patterns are declarative
就像我在 中提到的掌握结构模式匹配 ,这是从所有这些中带走的最重要的概念。写 Python 是势在必行。你告诉 Python 什么时候做什么。但是对于一个模式,你声明你想要的结果,并把思考留给 Python。例如,我没有在validate_currency中编写任何存在检查来检查发票是否有接收者!我把它留给 Python,这样我就可以专注于编写实际的业务逻辑。*
注意函数参数中可变的缺省值
Author Mickey Petersen
函数中的默认参数很有用。当你用缺省值写一个函数时,你应该理解其他开发者可能不希望改变或修改那些常见的或不常改变的选项。所有的默认都意味着一个契约——对你的程序员同事来说,既是一个技术契约,也是一个社会契约——它们不能被改变;如果它们改变了,你就有可能使你或你的开发伙伴第一次调用你的函数时所做的意图失效。但是如果你不小心的话,Python 语言设计的某些部分可能会破坏这个契约。
什么是可变性?
可变性是事物可变的另一种说法。当 Python 中的一个对象是可变的时,这意味着您可以改变它的内部状态,比如向字典中添加一个键值对,或者向列表中追加元素。
By the way …
注意这里的不变性只延伸到tuple和frozenset对象,而不一定是里面的元素!例如,列表元组是完全合法的。
相反,一个tuple,或者说frozenset,是不可变的。在创建它们之后,您不能添加或删除它们的元素 。您自己创建的用于保存状态的复合对象——如Employee对象中的salary字段——是可变对象的另一个例子。事实上,由于语言的设计,Python 中很少有东西是真正不变的:
>>> import math >>> math.answer = 42 >>> print(f'What is the answer to Life, the Universe, and Everything else? {math.answer}') What is the answer to Life, the Universe, and Everything else? 42
在这里,我导入了math模块,并向该模块添加了一个常量answer,以表明除了少数例外,Python 中几乎没有什么是真正不可变的。
可变默认值
既然我已经阐明了我所说的可变性是什么意思,那么有必要看看什么是可变默认值。但是问问你自己,你会想要什么?如果它是默认值,那么我们希望它是这样,因为它是静态的,不会改变。尽管单词默认并不意味着静态或不可变,但对我们大多数人来说,直觉认为,不,默认值不应该改变。
现在考虑一下,如果您有一个像这样的小脚本会发生什么:
# merge_customers.py from collections import namedtuple Customer = namedtuple("Customer", "age name") def merge_customers(new_customers: list, existing_customers: list = []): existing_customers.extend(new_customers) return existing_customers kramer = Customer(name="Kramer", age=42) george = Customer(name="George", age=37) merged_customers = merge_customers([george]) print(merged_customers) merged_customers = merge_customers([kramer]) print(merged_customers)
merge_customers函数只是将两个列表合并成一个并返回它。如果你只给它new_customers,那么它将使用默认参数existing_customer = []为你创建一个默认列表,然后将新客户合并到其中。如果您给它一个可选的现有客户列表,那么它当然会使用这个列表来代替默认值。
那么如果我像上面一样运行两次merge_customers会发生什么呢?
嗯…
$ python merge_customers.py [Customer(age=37, name='George')] [Customer(age=37, name='George'), Customer(age=42, name='Kramer')]
好吧,这可能不是你期望看到的。当我第二次调用它时,没有将默认值重置回空列表。
原因是 Python 在你运行源文件并赋值[]的时候评估了它一次——这是一个可变对象,记得吗?–作为existing_customers的默认值。对merge_customers的任何调用都将使用existing_customers的同一个对象实例。您可以通过打印对象的内部 ID 并用一个报告其被调用频率的自定义函数替换existing_customers = []来测试它。这样做,您将看到它们在调用之间实际上是相同的:
def make_list(): l = [] print(f"Creating a new list. ID={id(l)}") return l def merge_customers(new_customers: list, existing_customers: list = make_list()): print(f"ID={id(existing_customers)}") existing_customers.extend(new_customers) return existing_customers
运行修改后的版本会产生以下答案:
Creating a new list. ID=140657205032768
ID=140657205032768
[Customer(age=37, name='George')]
ID=140657205032768
[Customer(age=37, name='George'), Customer(age=42, name='Kramer')]
如您所见,make_list在文件运行时只被调用了一次。直觉上,您会认为它会被调用两次,但是现在您知道为什么它没有被调用了。事实上,id 也是一样的。
id()函数接受一个对象并返回一个唯一标识该对象的值。如果您想知道两个对象是否是同一个对象,这很有用。
那么解决办法是什么?好吧,解决办法是不要把任何可变的东西放在默认值字段中。默认为None并检查它,使用一个空列表更简单:
def merge_customers(new_customers: list, existing_customers: Optional[list] = None): if existing_customers is None: existing_customers = [] existing_customers.extend(new_customers) return existing_customers
因为对象是可变的,所以这是确保陈旧数据不会持久存储在不太可能的位置的简单方法。
当然,另一种方法是,你可以利用这个怪癖,围绕可变缺省值来构建你的软件。但是这是而不是推荐的:它是不直观的,并且它假设 Python 永远不会重新加载或重新评估模块。您还冒着被他人发现“错误”并修复它的风险,这会导致您的代码出现逻辑错误。
小心副作用
另一个与可变性相关的致命问题是带有副作用的代码。
有副作用的函数是改变(变异)存在于函数自身之外的状态的函数。例如,函数create_user可以在返回之前与数据库或 API 对话来创建用户。
出于与上述完全相同的原因,您应该避免编写可能改变状态的代码:
def open_database(connection = make_connection(host='foo.example.com')): print(f'Connected to {connection}!') # ... do something with the connection ... connection.close()
这段代码遇到了与前面的例子相同的问题。将调用open_database的结果设为默认值肯定会中断,因为连接只建立一次,并且后续调用会失败,因为在第一次调用后连接会关闭(并且永远不会重新打开)。此外,在加载文件时建立连接,这可能发生在文件被使用前的几分钟或几小时,此时连接可能已经超时;事实上,连接可能会在加载时失败,导致应用程序崩溃。
即使是看似无害的事情也会引发问题:
import datetime def print_datetime(dt = datetime.datetime.now()): return str(dt)
重复调用print_datetime不会返回当前时间。
import datetime CURRENT_DATETIME = datetime.datetime.now() def print_datetime(dt = CURRENT_DATETIME): return str(dt)
因此,总之,避免可变性的最佳方式是将默认值视为常量赋值。事实上,如果我稍微重写代码,就像我上面所做的,你会立刻发现问题,对吗?
摘要
You must avoid mutable arguments at all costs
可变缺省参数的合法用例很少。大多数人无意中编写了可变默认值,因为他们对 Python 如何评估和运行代码的直觉和知识是错误的。只有在用尽了所有其他选项后,才应该考虑使用可变默认值。确保你仔细地记录你的代码并解释你在做什么。
You should also avoid code with side-effects
即使是简单的事情,比如获取当前的时间和日期,也是错误的。它是在模块负载上评估的。总是假设默认值是常量,它们只执行一次,不会再执行一次。
Immutable objects like tuples or frozensets are usually safe to use as default values
但是永远记住不变性是一个特定对象的属性,而不一定是它所引用的任何对象的属性。可变列表可以包含不可变的元素;不可变元组可以包含可变列表。
用单个调度分离特定类型代码
Author Mickey Petersen
你有没有发现自己写了一长串夹杂着isinstance()电话的if-elif-else陈述?尽管有错误处理,但它们经常出现在你的代码与 API 交叉的地方;第三方库;和服务。事实证明,合并复杂类型——比如将pathlib.Path转换为字符串,或者将decimal.Decimal转换为浮点或字符串——是常见的事情。
但是写一墙的if-语句使得代码重用更加困难,并且会使测试变得复杂:
# -*- coding: utf-8 -*- from pathlib import Path from decimal import Decimal, ROUND_HALF_UP def convert(o, *, bankers_rounding: bool = True): if isinstance(o, (str, int, float)): return o elif isinstance(o, Path): return str(o) elif isinstance(o, Decimal): if bankers_rounding: return float(o.quantize(Decimal(".01"), rounding=ROUND_HALF_UP)) return float(o) else: raise TypeError(f"Cannot convert {o}") assert convert(Path("/tmp/hello.txt")) == "/tmp/hello.txt" assert convert(Decimal("49.995"), bankers_rounding=True) == 50.0 assert convert(Decimal("49.995"), bankers_rounding=False) == 49.995
在这个例子中,我有一个convert函数,它将复杂的对象转换成它们的原始类型,如果它不能解析给定的对象类型,它将引发一个TypeError。还有一个关键字参数,bankers_rounding用于十进制转换器。
让我们快速测试一下转换器,确保它能正常工作:
>>> json.dumps({"amount": Decimal('49.995')}, default=convert) '{"amount": 50.0}'
没错。确实如此。移除default=参数,dumps函数抛出异常,因为它不理解如何序列化Decimal。
但是现在我已经在一个函数中捕获了许多独立的逻辑片段:我可以转换数据,是的,但是我如何容易地测试每个转换函数实际上做了它应该做的事情?理想情况下,应该有明确的关注点分离。关键字参数bankers_rounding只适用于 Decimal 例程,但它被传递给了我们共享的convert函数。在现实世界的应用程序中,可能有许多转换器和关键字参数。
但我认为我们可以做得更好。一个简单的方法是将转换器逻辑分成不同的功能,每种类型一个。这样做的好处是,我可以独立测试和使用每个转换器。这样,我就为需要它们的转换器函数指定了所需的关键字参数。关键字bankers_rounding不会与不适用的转换器混淆。
其代码将如下所示:
def convert_decimal(o, bankers_rounding: bool = False): if not bankers_rounding: return str(o) else: # ... # ... etc ... def convert(o, **kwargs): if isinstance(o, Path): return convert_path(o, **kwargs) else: # ...
此时,我已经构建了一个调度器,它将数据转换的行为委托给不同的函数。现在我可以分别测试调度程序和转换器了。在这一点上,我可以放弃,但是我可以几乎完全摆脱convert调度器,通过将检查类型的逻辑卸载到隐藏在functools模块中的一个鲜为人知的函数singledispatch。
如何使用@singledispatch
首先,你需要导入它。
>>> from functools import singledispatch
在 Python 3.7 中singledispatch获得了基于类型提示的调度能力,这正是本文所使用的。
很像以前的调度程序,singledispatch使用的方法也是一样的。
@singledispatch def convert(o, **kwargs): raise TypeError(f"Cannot convert {o}")
singledispatch 装饰器的工作方式与上面的自制方法类似。你需要一个基函数作为任何未知类型的后备。如果将代码与前面的例子进行比较,这类似于代码的else部分。
此时,调度程序不能处理任何的事情,并且总是抛出一个TypeError。让我们重新添加十进制转换器:
1@convert.register 2def convert_decimal(o: Decimal, bankers_rounding: bool = True): 3 if bankers_rounding: 4 return float(o.quantize(Decimal(".01"), rounding=ROUND_HALF_UP)) 5 return float(o)
注意装饰者。singledispatch decorator 将基函数转化成一个注册表,用于将来你想注册的类型,这些类型与基函数的相对应。因为我使用的是 Python 3.7+版本,所以我选择了类型注释,但是如果你不希望这样做,你必须用@convert.register(Decimal)代替 decorator。
这个函数的名字是convert_decimal,当然它可以自己运行:
>>> convert_decimal(Decimal('.555')) 0.56 >>> convert_decimal(Decimal('.555'), bankers_rounding=False) 0.555
现在,我可以为每个转换器编写测试,并将复杂的类型检查留给singledispatch。
同时,我可以用完全相同的参数调用convert,它的工作方式与您预期的一样:我给它的参数被分派给我之前注册的convert_decimal分派器函数:
>>> convert(Decimal('.555'), bankers_rounding=True) 0.56
动态查询和添加新的调度程序
singledispatch的一个有用的副作用是能够动态地注册新的调度程序,甚至询问现有的转换器注册表。
def convert_path(o: Path): return str(o)
如果您想动态添加convert_path函数,您可以:
>>> convert.register(Path, convert_path) <function __main__.convert_path(o: pathlib.Path)>
如果您想要类型到底层函数的映射,convert.registry将向您展示它支持什么:
>>> convert.registry mappingproxy({object: <function __main__.convert(o, **kwargs)>, pathlib.Path: <function __main__.convert_path(o: pathlib.Path)>, decimal.Decimal: <function __main__.convert_decimal(o: decimal.Decimal, bankers_rounding: bool = True)>})
给定一个类型,您还可以要求调度程序告诉您要调度到的最佳候选函数:
>>> fn = convert.dispatch(Path) >>> assert callable(fn) >>> fn(Path('/tmp/hello.txt')) '/tmp/hello.txt'
@singledispatch的局限性
singledispatch函数很有用,但也不是没有限制。从它的名字中也可以看出它的主要局限性:它只能基于单个函数参数进行调度,而且只能基于第一个。如果你需要多重分派,你将需要一个第三方库,因为 Python 没有内置这个库。
另一个限制是singledispatch只适用于函数。如果你需要它来处理类中的方法,你必须使用singledispatchmethod。
摘要
singledispatch encourages separation of concerns
通过将类型检查从转换器代码中分离出来,你可以独立地测试每个函数,结果是你的代码更容易维护和推理。
Converter-specific parameters are separate from the dispatcher
这确保了,比方说,bankers_rounding只在理解它的转换器上声明。这使得其他开发人员更容易解析函数签名;它极大地改善了代码的自文档化特性;它减少了错误,因为您不能将无效的关键字参数传递给不接受它的函数。
singledispatch makes it easy to extend the central dispatcher
您可以在代码中将新的调度程序(并查询现有调度程序的注册表)附加到一个调度程序中心:一个公共库可以公开公共的可调度函数,每个使用调度程序的“分支”可以添加自己的调度程序,而无需修改原始调度程序代码。
singledispatch works with custom classes and even abstract base classes
基于定制类(包括子类)的调度是可能的,甚至是被鼓励的。如果您正在使用 ABC,您也可以使用它们来分派到您注册的功能。*