InspiredPython-中文系列教程-全-
InspiredPython 中文系列教程(全)
用 PyGame 制作自己的塔防游戏
Author Mickey Petersen
在这个课程中,你将学习如何使用 PyGame 从头开始编写一个 2d 塔防游戏。写一堆意大利面条式的代码很容易;但是编写可维护且易于扩展的代码却不是这样。塔防游戏是学习如何编写一个真实的游戏来测试你作为 Python 程序员的技能的绝佳场所。它也是许多其他 2d 游戏的完美模板。
This is what you’ll be building!
编写游戏的部分挑战是,一旦你超越了真正的基础,许多不同的学科就会抬头。在本课程中,您将学习以下技能:
What is a Game Loop?
游戏如何在屏幕上以一种易于维护和易于理解的方式更新和显示事物?
游戏循环是所有游戏的基石,无论大小。您将学习如何创建一个,以及如何用它来处理键盘和鼠标输入、图形渲染、更新屏幕上实体的物理特性等等。
State Machines and Transitions
很少有游戏只有一个屏幕,因此只有一种状态。大多数游戏都有一个主菜单,一个计分板,真正的游戏,可能还有更多玩家在游戏中互动的状态。理解如何在这些不同的概念之间转换你的游戏的状态对于编写一个没有意大利面条代码的游戏是至关重要的。
您将了解到有限状态机,这是计算机科学中的一个重要概念,以及它如何轻松地将一组复杂的令人困惑的需求转换成整洁的代码。
Lazy evaluation, Generators and Iterables
通过自由使用 Python 的itertools
库和生成器,可以很容易地跟踪事物的位置——并计算事物的下一个位置,例如飞行的子弹。
掌握 Python 的一个部分,这个部分得到了大多数开发人员的支持,因为它们比普通的 for 循环更难推理。
Drawing and manipulating graphics
了解什么是精灵,如何操纵它移动、旋转、缩放,以及如何以一种清晰、易于理解的方式高效地完成。
Level Editing
您将编写一个完整的关卡编辑器,能够使用您自己构建的简单 UI 来放置和编辑组成塔防游戏的所有实体。
关卡编辑器是游戏的核心部分,包括如何编写保存和加载功能的细节,所以你可以和朋友分享你的关卡。
Path finding and Recursion
了解递归,这是一个强大的编程概念,可以在地图中找到有效的路径让敌人通过。您将了解基本的图论以及深度优先搜索如何用于遍历地图并找到从起点到终点的路线。
Vector Mathematics
掌握确保子弹沿直线射向目标所需的数学知识;你的敌人顺利地穿过地图;以及如何使用简单的算法制作简单的基于文本的动画。
您将了解简单的矢量算术、插值和基本的仿射变换(如缩放和旋转)。
Object-Oriented Programming (OOP)
提高您对类和对象的理解,以及如何最好地利用继承、工厂模式和 Python 的数据类,使用简单的类简洁地描述您的游戏世界。
Animation
了解如何将图像帧链接到简单的动画中,以便敌人穿过屏幕并在被爆炸的投射物击中时倒下。
Collision Detection
重要信息:炮塔如何知道何时向它瞄准的敌人开火?子弹打在敌人身上呢?
你准备好了吗?我们来编码吧!
简介和课程计划
因此,在我们开始认真编写代码之前,让我们看一下课程计划,以及您将学到什么。
课程计划
-
简介和课程计划
-
课程计划
-
课程形式
-
配置 Python 环境
-
创建一个简单的 Python 包来托管我们的游戏
-
安装和运行演示游戏
-
游戏的媒体资产
-
-
游戏循环控制器和初始化 PyGame
-
状态机:它们如何工作,做什么?
-
为我们的游戏建立一个模板(你也可以在其他项目中重复使用)
-
精灵,精灵处理和如何与他们互动。
-
编写 2d 切片引擎和地图编辑器
-
精灵动画、运动学和矢量数学
-
路径查找和递归基础
-
碰撞检测和如何使用遮罩
-
菜单、声音、文本和摘要。
这是课程计划,以及我们将如何从这里开始。我已经做了特别的努力来确保我以我期望你把它们加入游戏的速度来介绍它们,而不一定是按照你自己写的顺序,如果你坐下来写你的第一个游戏的话。我认为,这是我想重复的一个要点:将东西绘制到屏幕上还有一段时间,因为我认为在继续之前我们打下良好的基础是至关重要的。
必需的知识
在您开始之前,我将简要介绍一下我认为您应该了解的内容。你知道这一点并不是一条铁定的规则,但这将使课程更加平易近人。
A basic understanding of Python
列表、循环、字典、类、函数等等。你不一定要成为面向对象编程专家,但是了解一点关于继承的知识会大有帮助。
我们将编写自己的 Python 包,这主要是一个注意细节的例子。但是知道如何用pip
安装包以及你的平台可能有的任何其他补充需求(这主要与 Linux 用户相关。)
Basic mathematics
没错。数学。这是无法逃避的。主要是算术,但是我们将转向向量数学的主题——但是仅仅是基础。如果你熟悉简单的笛卡尔坐标系和加减数字,你会做得很好。
差不多就是这样。你不需要事先了解 PyGame 或游戏开发;但是你确实需要一种想要学习它的渴望!
图形资产
这款游戏配有高质量的图形,随时可用。你可以在演示中找到它们。
课程形式
本课程不是你在游戏演示中找到的源代码的对等重复,但我会解释它的所有方面,所以在本课程结束时,你将完全理解编写塔防游戏(和许多其他 2d 游戏)所需的一切!)或者坐下来修改提供的演示中的所有内容。提供的演示完全正常;它有资产,音效,一个工作级编辑器和游戏。它还展示了您将在本课程中读到的所有内容,因此,如果您遇到困难,或者更喜欢查看完整的解决方案,我们鼓励您回头参考它。
您可以通过多种方式学习本课程,每种方式都有自己的学习途径,具体取决于您的喜好:
-
你可以拿着这个演示开始修改,并把它作为基线或者作为你自己游戏项目的灵感。
-
您可以将本课程作为参考,仅当您需要关于特定部分的建议时才回头参考,并通过提供的演示巩固您的学习成果。
-
你可以按照课程和实现每一件事,一步一步,一路试验。
或者以上两种情况的结合——选择权在你。
好了,我们开始吧!
让我们用 Python 写一个 Game Boy 模拟器
原文:https://www.inspiredpython.com/course/game-boy-emulator/let-s-write-a-game-boy-emulator-in-python
Author Mickey Petersen
对于 20 世纪 80 年代和 90 年代古板、陈旧的游戏平台,有很多东西值得一提。其中最主要的是怀旧——如果你足够大,还记得它们的话——以及对游戏和计算机硬件都更简单、更容易理解的时代的喜爱。
The 4.194304 MHz monochrome Game Boy released in 1989.
但对于那些不知道的人来说,Game Boy 是一款标志性的便携式游戏设备,旨在使用你插入设备背面的墨盒来玩电脑游戏。最初的 Game Boy 是单色显示器;一个 8 位 Z80 风格的 CPU,4.194304 MHz 高达 8 KiB 的工作内存和 8 KiB 的视频内存;和 4 个立体声声道。它还有一个液晶显示屏、一个方向游戏手柄和几个按键。热门游戏包括超级马里奥、俄罗斯方块,当然还有神奇宝贝。
在这门课中,我将教你如何用 Python 写一个 Game Boy 模拟器。
什么是模拟器?
先简单说一下什么是仿真器什么是。在我们的例子中,我们将编写的 Game Boy 模拟器将模仿我们需要的组成 Game Boy 的许多组件,以使其工作。但是,这句话有很多模糊的上下文:模仿某事是什么意思?你是如何模拟 LCD 的复杂性的——你甚至尝试过或者仅仅满足于在屏幕上画图吗?CPU,内存和物理按钮呢?那保存游戏的墨盒呢?
决定什么是好的或坏的模拟器归结为仔细的权衡。将源系统的所有硬件缺陷或软件缺陷完美地呈现给主机系统并不总是可行的(或有用的)。还有其他的考虑,比如主机系统是否能够准确地模拟它。
因此,一个模拟器编写者的工作是选择你的战斗,并承认有些事情(在我们的例子中是某些游戏)如果没有不成比例的工作量对的投资回报,就无法工作。不同的硬件如何相互作用的复杂性,以及程序员为保证每个周期的性能所经历的极端长度,意味着如果你真的想要一个周期精确的仿真器,你必须实现大量晦涩难懂的行为。现在,随着教育的“投资回报”急剧下降,我将不再努力追求那种水平的竞争。
一个周期精确的仿真器试图模仿原始系统的特性,使得仿真系统的时序尽可能接近真实系统。某些游戏和应用程序需要实现这种级别的模拟,但并非所有游戏和应用程序都需要。
但话说回来,这些错综复杂的组件如何交互绝对是我们会遇到的事情:有许多边缘情况和设计怪癖,我们必须权衡试图准确反映真实系统的好处,而不是简单地宣布它“足够好”并继续前进。
因为这是一门教育课程,所以主要的重点是教授一些概念,这些概念会产生一个能够运行一些 Game Boy 卡盒的尚可的仿真器。因此,即使你表面上对 Game Boy 不感兴趣,你从课程中学到的东西也会让你对软件和系统编程有深刻的理解。
目标受众
你不需要成为 Python 或系统编程方面的专家,也可以尝试一下。整个课程旨在温和地介绍你将要学习的每个概念。这并不意味着你会坐下来,在一个周末就把它完成,即使你是;这是一项相当大的任务,但值得庆幸的是,回报是构建模拟器的体验。
你会学到什么
CPU Design and CPU Architecture
Z80 风格的 CPU 与现代英特尔 CPU 可以追溯到的基础 8080 CPU 设计非常相似。虽然 Z80 与 Game Boy 中的 CPU 相似,但并不完全相同:它很可能是一款名为 LR35902 的英特尔 8080 风格的夏普 CPU,但我将使用 Z80 这个术语,尽管它不是 100%真实的。原因是除了提到 Game Boy 之外,互联网上关于 Sharp CPU 的文档很少。Z80——尽管它在许多方面与夏普不同——相当相似,并且有大量在线文档。
当然,现代的 CPU 非常复杂;但是旧的 Z80 不是,这使得它非常适合理解 CPU 如何实际工作的基本原理。
您将使用 Python 编写一个完整的“CPU ”,包括寄存器、标志、内存控制器、指令执行等等。
Assembly Language Programming
您将很好地理解 Z80 汇编语言(以及至今仍在使用的一般概念)以及它是如何支撑我们今天所做的一切的。基于寄存器的 CPU 很可能是你正在阅读的计算机的动力,并且对汇编语言有足够的了解,可以令人信服地推理出 CPU 如何机器码被解码并执行,这将很好地为你服务。
How to write a Disassembler
如果汇编程序将汇编语言转换成 CPU 可以理解的机器代码,反汇编程序则相反:它将机器代码转换回汇编语言。作为模拟器工作的一部分,当你有条不紊地研究 CPU 规范或调试开发过程中会出现的问题时,你需要这样做。
Interrupt Handling
问问你自己,当你按下 Game Boy 游戏手柄上的键时会发生什么?对于忙于执行组成电脑游戏的代码的 CPU 来说,物理按钮的按下是如何表现为可操作的事情的?这个问题(以及许多其他事件)的答案是中断处理程序,这是系统编程的另一个核心部分,它允许在完全没有并发性的 CPU 中处理事件。
Interactive Debugger
您将编写一个交互式调试器,能够单步执行每条机器代码指令;评估简单的表达式,如查看内存或寄存器;当到达代码的特定部分时放置断点;还有更多。
Bit manipulation (or “bit twiddling”)
问问你自己,一台只理解 0 和 1 的计算机如何理解数字 2?它是如何把加到那个数上的,一个 CPU 只能对 8 位或者 16 位数进行运算是什么意思?课程结束时,你将对二进制数有一个坚实的理解;CPU 如何加减和表示有符号和无符号数;如何对二进制数进行“位操作”,例如在位域中设置或重置标志;以及《格列佛游记》和《CPU 字节序》令人费解的词源。
Vertical Blanking, Blitting, Scrolling and Sprites
将东西画到屏幕上是 CPU、内存、显示器及其显示控制器之间精心编排的芭蕾舞。在真正的硬件上出错,你只会打印垃圾,甚至损坏它!幸运的是,这在模拟器中并不重要,但是你仍然需要理解屏幕空白;向屏幕发送信息;以及 Game Boy 如何滚动屏幕并显示移动图形。
Performance Profiling and Code Optimization
Python 是…不快的。您可能会惊讶地听到,即使半精确地模拟一个不起眼的 4.19 MHz CPU 也会对您的 CPU 造成影响!所以一旦该说的都说了,该做的都做了,你就会学到一些加快速度的技巧。
Python
如果我不包括这一点,我将是失职。在整个课程中,我打算向你展示我将如何在考虑到可测试性和良好设计的情况下开发仿真器的关键部分。我将使用 Python 3.10 的 匹配案例模式匹配 ,因为它有许多诱人的用例,并且这里有很多关于关注点分离的内容——当你必须开发一个复杂的组件网络,但仍然必须独立编写和测试时,这是一个关键的概念。
目标和非目标
让我们来谈谈目标和非目标。目标是一个正常运行的 Game Boy 模拟器和一个调试套件来帮助进一步开发它;但是旅程比目的地更重要。在这个过程中,你会学到很多非常酷的概念和技术。完成一个项目感觉很好,但是这是一项任务,超过 70-80%的每一个百分点的增量都将花费越来越多的时间,因为你将不得不获得许多复杂的时间安排和设计怪癖来取得进展。那么, me 的最终目标就是尽我们所能,并从中获取乐趣和学习,这远远达不到的周期精度。但这不应该让你气馁:一旦你很好地理解了 Game Boy 仿真的机制,你就应该拥有自己进一步发展所需的工具和知识。
所以,简而言之:
Emphasis on Readability over Performance
我写的代码将强调可读性,而不是性能。当您还不了解瓶颈可能在哪里或在什么地方时,很容易陷入不必要的性能优化。
课程计划
如果你想写一个 Game Boy 模拟器,有相当多的必读材料,但是关于这个主题的主流文档——虽然非常好——确实假设了系统编程的流利程度,我当然会首先解释。
所以计划大致如下:
-
简介(这就是你现在正在读的东西
-
什么是游戏机,什么是模拟器
-
建议和要求的资源
-
从操作码 JSON 中读取操作码
-
-
理解操作码和操作数
-
如何读取和实现操作码和操作数
-
编写一个基本的反汇编程序(和我们未来的解码器)
-
什么是盒式只读存储器?
-
从盒式磁带中读取元数据
-
-
编写 Z-80 CPU 的 Python 框架
-
程序计数器
-
寄存器和标志
-
钻头旋转基础
-
-
存储体和存储控制器
-
编写交互式调试器
-
实现一些基本的 CPU 操作码
-
比特旋转和操作数字
-
ALU 和算术操作码
-
中断处理
-
图形、平铺地图、背景和显示
-
游戏手柄和内存条切换
-
性能优化
-
声音
建议和要求的资源
你不能在真空中编写游戏模拟器。这条路线站在巨人的肩膀上。有大量的发烧友为游戏男孩的各种化身制作的文档。Game Boy 经过了很多很多年的艰苦逆向工程,已经成为我们可以利用的文档和资源的集合。
也有许多其他的游戏机模拟器,如果你被卡住了,看看他们是怎么做的也没什么不好意思的。你会被卡住的。一旦你更进一步,你就不会有任何问题去推测这些模拟器是如何工作的,并且你可以很快检查你的理解是否正确。
我将包括与我们进展到的每个阶段的进展水平大致匹配的代码示例,但有一点需要注意的是,像所有半成品一样,它当然不会是 100%正确的参考实现——您只有在最后才真正到达那个阶段——但它应该很好地作为 you 的参考或起点。随着我们对 Game Boy 及其内部的理解不断加深,每一次进步都会带来变化、修正和改变。
所需资源
我应该先列出 Game Boy 开发社区,因为这是一个关于 Game Boy 几乎每个部分的精彩网站。你在下面看到的很多信息也可以在上面的 GBDC 链接中找到。
技术参考
《潘文档》是游戏男孩各方面的优秀技术参考。这是我们将使用的两个主要文档来源之一。我会经常提到这一点。
我强烈建议你浏览一下,感受一下内容是如何编排的,以及编写一个 Game Boy 模拟器需要什么。但是,不要让大量的概念、术语和你需要做的事情使你气馁,尽管我们将一步一步地循序渐进。
另一个很棒的参考手册是 Game Boy CPU 手册。它包含更详细的描述,特别是 CPU 操作码和系统的各个方面如何相互作用。你应该阅读并收藏这两本。因为后者是没有超链接的 PDF,我建议你打印出来。不过,请注意,文档中有一些不准确的地方;鼓励您将信息与完整技术参考进行交叉参考。
Game Boy:完整的技术参考是另一个资源,它也非常好,特别关注内存库(暂时不用担心)和 CPU 指令。
尽管作者声称这本书“陈旧且无人维护”,但实际上它仍然是一本有用且相关的参考手册。
操作码引用
有一个 Game Boy CPU 指令周期表,这是一种有趣的显示信息的方式,你可以在我上面列出的技术参考中找到相同的信息。非常有用的是同样的 JSON 下载;稍后我将使用它来自动化编写反汇编程序的许多繁琐工作。
盒式 ROMS
优秀的自制中心充满了游戏、演示和各种各样的酷 rom 供你尝试。不言而喻,我不能告诉你如何下载你以前玩过的 game boy 盒式光盘,因为这当然侵犯了版权;但是仍然有大量的免费 rom 供你尝试。
模拟器
有许多针对 Game Boy 的仿真器,具有不同的硬件精度和特性。为了开发一个 Game Boy 模拟器,我推荐你看看 BGB,因为它有一个优秀的调试器。我可以确定它在 Windows 上运行得很好,在 Linux 上运行 WINE 也很好。
可选但有用的资源
汇编程序和操作码手册
如果你想让为游戏男孩编写汇编语言 rom,RGBASM 文档和 Rednex 游戏男孩开发系统是很有用的。如果你想用一个真正的 ROM 测试某些 CPU 指令,你可以。该文档是 Game Boy 汇编语言编程的入门读物。
精灵和瓷砖
游戏男孩图形渲染器很有用,在很久以后,如果你想检查你的 sprite 和 tile 图形实现是否正确。你可以给它输入一串字节,它会告诉你它的图形。
读取和解析操作码和操作数
操作码和操作数
一个操作码是 CPU 必须对操作码的操作数执行的动作,如果有的话。因此,如果1 + 2
是人类的指令,那么操作码将是 CPU 的+
,而1
和2
是操作码的操作数。
你写的所有代码都会以这样或那样的方式,把自己简化成一组你运行代码的 CPU 能够理解的指令。需要多少步骤取决于你使用的语言和工具:写汇编语言,差距很小;写 Python 是一个巨大的鸿沟。在许多方面,像 Python 这样的解释的语言类似于 CPU,因为解释器模仿了组成计算机的大部分架构,以便提供一个“统一的”环境,在那里你可以编写一次代码,并合理地假设它将在解释器运行的地方运行。
因此,解释型语言的好处是,如果你能编写解释器并使它在深奥的计算机硬件上工作,那么你就能期望为解释器编写的大量工作也能运行。具体来说,Python 是在一个虚拟机上运行的,我认为这很能说明问题。
考虑以下代码片段:
>>> def add(a, b): return a + b >>> import dis >>> dis.dis(add) 2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_ADD 6 RETURN_VALUE ^--- Mnemonic ^^^^^ Argument ^--- Offset
使用dis
模块,我可以通过它的dis.dis()
函数运行任何对象,并且将对象分解成它的组成指令。尽管 Python 使用的术语与 Z80 CPU 看待事物的方式并不完全一一对应,但我认为这是一个合理的复制。我建议你用 Python 反汇编一些东西,感受一下 Python 的 VM 如何理解你的代码。注意,dis.dis
函数没有显示操作码,但是dis.get_instructions()
显示了。
同样,您可以要求 Python 为您提供我们的函数add
的字节编译代码:
>>> add.__code__.co_code b'|\x00|\x01\x17\x00S\x00'
By the way …
类似于 ??,我必须指出 Python 的字节码在实现中有各种各样的余量来支持 Python 的动态特性。这意味着具体化的字节码不是源代码的 100%镜像。不过,这不是 Z80 的问题。
字节码是我们代码 的浓缩表示,Game Boy 卡带 rom 也是如此。盒式只读存储器也存储数据——图形、音乐等等——你不一定能以原始字节形式区分代码和数据。
如果你从 rom 中挑选出一个值为144
的字节,你怎么知道它是代码还是一段音乐的一个小片段呢?
获取操作码元数据
我们将要模拟的 Z80 有大约 500 条指令。这听起来可能很多,但大多数都是彼此的变体,我们可以通过一些巧妙的思考一次敲出几把。
好,我们需要一个完整的操作码列表。你在上面看到的反汇编代码是这些东西如何被写出供人使用的一个相当典型的代表。它通常看起来有点像这样:
<addr> <opcode> <mnemonic> [<operand> ...] [; commentary ]
addr
是指令所在的内存地址——我们现在可以忽略它——还有你现在知道的opcode
和mnemonic
;operand
条目的列表是可选的,因为不是所有的操作码都有它们,但是如果它们在那里,我们会显示它们。最后是一个带有;
的评论,表示一个评论。
我建议你从操作码表下载 JSON 并使用它。你没有有去:你当然可以复制你在表格或参考手册中看到的说明。
操作码 JSON 分为两个主要部分:cbprefixed
和unprefixed
。现在,我建议您将每一个都视为不同的操作码段。有一个很好的理由,我们将详细讨论 ROM 中操作码的结构以及如何读取它们。目前,惟一的目标是获取这个结构化的 JSON 文件,并将其转换成可以用 Python 轻松查询的内容。
任何一个键本身都是一个{ opcode_1: details_1, ..., opcode_n: details_n }
的字典,就像这样:
"0xFF": { "flags": { "C": "-", "H": "-", "N": "-", "Z": "-" }, "immediate": true, "operands": [ { "immediate": true, "name": "38H" } ], "cycles": [ 16 ], "bytes": 1, "mnemonic": "RST" }
这是一条带有助记符RST
的指令。您能使用我之前编写的模板挑选出编写人类可读表单所需的信息吗?
指令和操作数数据类
解析这个文件应该是一个 zinch。格式绝对可以保持原样;它足够详细,使人类可读。
但是,我更喜欢数据类:
from dataclasses import dataclass from typing import Literal @dataclass(frozen=True) class Operand: immediate: bool name: str bytes: int value: int | None adjust: Literal["+", "-"] | None def create(self, value): return Operand(immediate=self.immediate, name=self.name, bytes=self.bytes, value=value, adjust=self.adjust) @dataclass class Instruction: opcode: int immediate: bool operands: list[Operand] cycles: list[int] bytes: int mnemonic: str comment: str = "" def create(self, operands): return Instruction(opcode=self.opcode, immediate=self.immediate, operands=operands, cycles=self.cycles, bytes=self.bytes, mnemonic=self.mnemonic)
Python 中的十六进制
不熟悉十六进制?没问题。快速速成课程。(稍后我们还将讨论二进制,因为它更重要!)
您可能已经看到操作码字典中的键是这样的字符串:"0xFF"
。十六进制使用的基数为 16,而不是十进制(基数为 10)或二进制(基数为 2)。在二进制中,你用0
和1
计数;十进制用0
通至9
;而十六进制是0
到9
,然后A
到F
产生一个“数”的序列像这样:0123456789ABCDEF
。
十六进制通常以0x
(或 Z80 行话中的$
,但 Python 不识别该符号)为前缀,二进制以0b
为前缀。在 Python 中尝试一下:
>>> 0xFF 255 >>> 0b1111_1111 255 >>> hex(255) '0xff' >>> bin(255) '0b11111111'
一个字节可以代表0-255
,与0x0-0xFF
相同。每个字节为 8 位,通常分为两个各 4 位的半字节:
>>> 0b0000_1111 15 >>> hex(15) '0xf' >>> 0xF 15
是的,你可以把_
塞进数字里,包括十六进制和二进制符号,作为一种视觉辅助。很酷吧。
这两个半字节通常被称为“高”和“低”。哪一个是高或低——左侧或右侧——归结为 CPU 的字节序,这个话题我们将在稍后认真讨论二进制数时讨论。
具有讽刺意味的是,除了让人类更容易推理出二进制数之外,没有理由使用十六进制数,因为十六进制数、字节、位和半字节都是 2 的幂:2、4、8、16。
现在,因为 Z80 是一个 8 位的 CPU(16 位支持算术和寻址),你需要一次处理半字节、位和(最多)2 个字节。十六进制使它更容易,但如果你喜欢,你可以自由使用小数!
解析操作码
所以回到操作码解析器。我们已经看到,操作码有十六进制值的字符串表示。但是我们需要先对它们进行解析。幸运的是 Python 可以为我们做到这一点:
>>> int("0xFF", base=16) 255
int()
函数接受一个可选的基,它也能够理解自己的符号:
>>> int("0b0110111", base=2) 55
所以这应该能解决问题。
说到底,我现在有两本词典,包含每一套独特的说明:
>>> instructions[0xFF] Instruction(opcode=255, immediate=True, operands=[ Operand(immediate=True, name='38H', bytes=None, value=None, adjust=None) ], cycles=[16], bytes=1, mnemonic='RST', comment='')
有了它,我可以很快得到一个独特的列表,里面列出了每种字典的所有助记符:
>>> {inst.mnemonic for inst in instructions.values()} {'ADC', 'ADD', 'AND', 'CALL', # ... etc ... 'SUB', 'XOR'}
这样,你就有了一个操作码的工作列表。我建议您为指令和操作数编写一个漂亮的打印机,这样您就可以看到类似于我之前展示的模板的文本表示。
结论和下一步措施
Opcodes and operands is the machine code that powers your computer
我们已经简要地介绍了 CPU 是什么,它做什么——以后还会有更多的介绍——以及操作码和操作数的作用。它是你的 CPU 的编程语言。但是正如我所展示的,像 Python 这样的高级语言及其解释语言;编译时发出的字节码;它用来运行字节码的虚拟机,与 CPU 的角色没有什么不同。
Hexadecimals, Binary and Numbers writ large
十六进制是程序员的助手。除了帮助我们推理二进制数之外,它们与计算没有什么关系,这是一个我还没有涉及到的话题,因为要真正理解它们需要深入研究。可以说,二进制数是 CPU 的命脉。
Emulation is the act of replicating the conditions that allow code to run unmodified on a different host
但是要注意的是,并不是所有的东西都那么容易被复制!让事情变得恰到好处既是一门艺术,也是一门科学。我们的目标是编写一个 Game Boy 模拟器,但要围绕、周期准确度和性能进行权衡。
The Game Boy CPU is a hybrid of multiple different CPU types
我将它称为 Z80,因为它相当接近 CPU,但它并不完全相同。这是一款夏普 LR35902,它的灵感绝对来自英特尔 8080 和 Z80。但是在线上没有关于该模型的官方参考文档,您可以找到的大多数文档都将您引回到 Game Boy 模拟器。
如果你想阅读 Z80,记住夏普有一个不同的指令集(但有很多重叠);它缺少一些寄存器和标志。
后续步骤
在下一部分,我们将看看如何编写一个反汇编程序,并使用我们刚刚读到的操作码。知道如何表示 CPU 将要执行的代码是一个重要的调试助手。
掌握结构模式匹配
原文:https://www.inspiredpython.com/course/pattern-matching/mastering-structural-pattern-matching
Author Mickey Petersen
如果你不熟悉术语结构模式匹配,那么你并不孤单。直到大约 10-15 年前,这个特性在函数式编程语言之外是看不到的。然而,它的使用已经普及;今天你可以在 C#、Swift 和 Ruby 中找到类似的特性。Python 3.10 曾经是小众语言的领地,现在你可以尝试了。
1def greet_person(p): 2 """Let's greet a person""" 3 match p: 4 case {"greeting": greeting, "name": name}: 5 print(f"{greeting}, {name}") 6 case {"name": name}: 7 print(f"Hello, {name}!") 8 case {"greeting": _} | {}: 9 print("I didn't quite catch your name?") 10 case str() as person if person.isupper(): 11 print("No need to shout - I'm not deaf") 12 case str() as person: 13 print(f"Nice to meet you, {person}.") 14 case _: 15 print("I didn't quite understand that!")
函数式编程学派的信徒们肯定会喜欢它;不得不与无数业务规则引擎纠缠不清的经验丰富的开发人员也可以期待一些缓刑。但是日常用例呢?是什么让结构模式匹配对典型的 Python 项目有用?它到底是什么,当你不用 it 就能解决复杂的问题时,你为什么要采用它?
总的概念——我将很快向您介绍它是如何工作的——是计算机科学和(尤其是)函数式编程的核心。渗透在所有这些不同的语言和他们自己对这个特性的理解中的是一个共同的词汇和对什么是模式匹配以及它试图解决的问题的理解。一旦你掌握了 Python 中模式匹配的要点,你就会认识到——并且知道如何应用——这些概念。
诱人的是,我留下了预示上述新特性的代码片段。看起来不算太糟,对吧?这是一个尝试智能格式化问候语的功能:
>>> greet_person({"greeting": "Say my name"}) I didn't quite catch your name? >>> greet_person("Walter") Nice to meet you, Walter. >>> greet_person({"greeting": "Howdy", "name": "Cosmo"}) Howdy, Cosmo
但是在greet_person
中,没有什么是你不能用一系列if
语句完成的。这就是模式匹配试图做的事情的关键所在:删除if
语句和“getter”的冗长和乏味,这些语句和“getter”询问对象的结构以提取您想要的信息。在greet_person
中,我希望——理想情况下——几条信息:一条greeting
和一条name
,并且在它们中的一些或全部丢失的情况下,处理得当。
操纵数据结构是编程的核心部分,模式匹配系统可以帮助您实现这一点。当您对对象、字典、列表、元组和集合使用if
语句、isinstance
调用、异常和成员测试时,您这样做是为了确保数据的结构匹配一个或多个模式。这就是一个特别模式匹配引擎的样子。
以传统的方式考虑上面的match
代码:
def greet_person_alt(p): msg = "I didn't quite understand that!" if isinstance(p, dict): if 'greeting' in p: greeting = p['greeting'] if 'name' in p: name = p['name'] msg = f"{greeting}, {name}" else: # ... etc ... else: # ... etc ... else: # ... etc ... print(msg)
这只是整个磨难的一部分,我也没有努力变聪明。但是正如您所看到的,深度嵌套的if
语句很容易遗漏一条业务规则或者将它放在错误的位置;更糟糕的是,您必须解析整个结构,以找出进行更改的正确位置。更不用说它的大小了。只需添加一些规则或复杂的检查来确定正确的问候格式,您将不得不创建您自己的 home brew 匹配引擎——这种方法根本无法扩展。
这就把我们带到了结构模式匹配的核心:关键词match
和case
。在编程的每个方面,这都是一个已经存在并将会存在的问题:
-
你是否有一个非常深的嵌套字典,你必须检查是否有键和它们的值?你可以使用结构模式匹配器。
-
您是否有依赖于自定义对象(如
Customer
或Sales
对象)中某些属性的复杂业务规则?你可以使用结构模式匹配器。 -
您必须解析来自其他系统的文件输出或数据流吗?可能从一系列原语(字符串、整数等)中转换它们。)到一个
namedtuple
,字典还是自定义 dataclass 对象?你可以使用结构模式匹配器。
所以让我们来看看它到底是如何工作的。
模式匹配器语法剖析
match
声明
match <expression>: case <pattern 1> [<if guard>]: <handle pattern 1> case <pattern n> [<if guard>]: <handle pattern n>
好了,现在是时候介绍一些术语了。match
语句是一个软关键字,并带有一个表达式(想想:类似于变量赋值的右边),它成为你的case
子句的主题。
软关键字,如match
语句,是这样一个关键字,如果在明确属于match
模式匹配块的而不是部分的上下文中使用,它不会导致语法错误。
这意味着你可以继续使用match
作为变量或函数名。
match
语句不是函数,也不返回任何东西。它简单地划分了一个或多个case
子句的开始,就像一串if
语句。
当 Python 执行模式匹配器代码时,它只是按照编写case
子句的顺序,检查第一个匹配的子句。可以有多个匹配的case
子句,但是只使用它遇到的第一个与匹配的子句。因此,排序很重要。
The match
statement picks the first match that evaluates to true
所以试着按照你希望匹配的顺序排列case
语句。从这个意义上来说,这类似于您如何对一系列if-elif-else
语句进行排序。
case
条款
每个case
子句代表一个或多个模式,您希望将它们与match
语句中定义的主题进行匹配。
在类 C 语言中,你必须在switch-case
语句中使用break
,否则代码将直接进入下一种情况。这在这里是不可能的:最多有一个case
条款被执行。的确,C-likes 中的switch-case
与模式匹配完全不同,所以不要把它们混淆。
case
子句采用一个或多个模式。每个模式可以依次拥有自己的子模式。
By the way …
一个case
子句可以选择有一个守卫,这是一个if
语句,允许您应用布尔条件,这些条件必须为 true 以匹配case
子句。有点像列表理解中的if
语句。
每个case
子句获取一个语句代码块,如果子句是match
块中第一个匹配主题的子句,则执行该语句代码块。如果您想使用return
、yield
或者,比方说,与case
子句语句块中的数据库对话,您可以而且应该这样做。如果主题匹配,就在这里放置所有必须调用的逻辑。
The match-case
statements may well become the center of your code in some applications
有限状态机;具有声明性模式和递归的行走树和树状结构;微服务中处理传入请求的无限循环;ETL 应用程序的一部分,在生成 JSON 并放入另一个系统之前,从一个活动系统中读入原始数据。天空是极限。
什么是模式?
我需要提到的第一件事是,你将在case
语句中编写的代码与你将在语句外编写的代码完全不同!
当你写一个模式时,你描述了结构,case
子句应该测试主题。这为你打开了许多其他方式无法获得的途径。您可以深度嵌套字典、列表和元组,Python 的匹配引擎将细致地打开每一层,并检查该结构是否与任何case
子句匹配。
考虑之前的例子:
def greet_person(p): """Let's greet a person""" match p: case {"greeting": greeting, "name": name}: print(f"{greeting}, {name}") # ... etc ...
让我们仔细看看那条case
条款。它只有一种模式,这种模式要求:
-
科目是一本字典。
-
字典至少包含两个键,一个名为
"greeting"
,另一个名为"name"
。 -
并且这两个键的值被绑定到命名绑定
greeting
和name
。
因此,如果您传递给greet_person
任何不符合这三个标准的东西,case
语句匹配失败,match
语句继续下一个case
语句。
什么是捕获模式?
到目前为止,唯一令人困惑的部分是绑定名称。是的,它们看起来非常像变量。但是它们是而不是变量,即使它是你代码中任何其他部分的字典,它们也会是。这是因为它是一个捕获模式,是模式匹配引擎的重要组成部分。
当 Python 必须将主题映射到case
块中的模式时,它可以将找到的值绑定到您给定的名称。它们被称为名称绑定或绑定名称,因为它们是作为模式匹配过程的一部分被捕获的。一旦它们被绑定,你就可以像使用变量一样使用它们。至关重要的是,只有当 Python 试图进行模式匹配时,它们才表现出不是变量的短暂性质。
事实上,如果case
子句成功,我们print
问候,在这一点上,一切又有意义了。
You can use named bindings to match large swathes of the subject
所以你绝不仅仅局限于一本字典的价值。你很快就会看到,我们能做的远不止这些。
但是请记住,命名绑定不是变量。还有一个尴尬的问题,当一个模式部分匹配,但最终失败时会发生什么。但是我将在后面的章节中讨论这些问题,因为它们也值得仔细研究。
A named binding itself matches (or not!) parts of your pattern
事实上,我可以捕获字典的值,但是当然有一个隐含的假设:键首先存在,并且具有某个值,即使这个值是None
。
因此——这一点很重要——命名绑定本身会影响您希望主题匹配的模式。
A pattern is declarative and not imperative
回想一下命令式编程正在编写告诉 Python 做什么的代码。有了模式你就不用告诉 Python 该做什么;相反,你声明你想要的结果或结局,并且你期望 Python 能找出本质的细节。
By the way …
特别是 PEP-634、PEP-635 和 PEP-636
这非常重要,如果你想真正理解模式匹配是如何工作的,记住模式是声明性的是至关重要的。考虑一下之前的例子:Python 是如何做的?我的意思是,它是在几个 PEP 规范 中记载的,当然还有模式匹配器的源代码。
但是——除了问题和引擎限制——这在这里并不重要。要使用结构化模式匹配引擎,您必须定义对您和 Python 有意义的模式,并相信 Python 会想出如何为您找到答案。
现在你知道了,模式是一种表达期望的结构的方式,一个主题必须具有该结构才能与模式匹配。这种结构几乎可以是任何东西。但是你也可以提取你最感兴趣的结构部分。这是使结构模式匹配有用的关键部分。
写作模式
文字模式
理论上,最简单的模式类型,文字模式匹配文字,如字符串、布尔值、数字和None
。
def literal_pattern(p): match p: case 1: print("You said the number 1") case 42: print("You said the number 42") case "Hello": print("You said Hello") case True: print("You said True") case 3.14: print("You said Pi") case _: print("You said something else")
字面模式匹配器必须做出许多假设,以大多数人对 Python 的直觉的方式工作。这意味着要制定一些明确的例外,否则大多数人会感到困惑。
文字模式检查是通过相等检查(a == b
)进行的,但是有几个特殊情况的异常和陷阱你应该知道。
>>> assert 1.0 == 1 >>> assert 1.1 != 1
浮点和整数通过相等检查进行比较。所以一些浮点数自然会等于它们的整数对应物。
您可以使用类型约束int()
或float()
强制 Python 选择其中之一,如下所示:
case int(1): print("You said the integer 1") # or case float(1.0): print("You said the floating point number 1.0")
如果将布尔值与0
和1
文字混合使用,则需要预先考虑:
>>> assert True == 1 >>> assert isinstance(True, bool) >>> assert isinstance(True, int)
True
和False
都是bool
和int
,因此True == 1
和上面的文字模式示例中的case True
子句永远不会运行,因为case 1
首先匹配它!
解决方法是确保case True
语句在 case 1
之前运行。这将解决问题:1
将匹配case 1
,True
将匹配case True
。
原因是True
、False
、None
通过身份 ( a is b
)匹配,像这样:
>>> assert True is not 1 >>> assert False is not 0
在大多数代码库中,这不会是一个问题,但仍然值得了解。我推荐你阅读 真理和谬误 来理解为什么混淆平等和身份验证会让你陷入困境。
作为模式
当您编写模式时,您可能希望在您的模式中进行某些声明,Python 必须遵守这些声明以使模式匹配。但是如果您还想将该声明绑定到一个以后可以使用的名称,那么您必须使用as
模式。
def as_pattern(p): match p: case int() as number: print(f"You said a {number=}") case str() as string: print(f"Here is your {string=}")
这里有两种模式。一个是类型声明,必须匹配字符串,另一个是整数。注意,与文字模式中的例子不同,我没有指定特定的字符串或整数,尽管我当然可以。
当我调用代码时,它会如您所料地工作,因为as
语句将左边匹配的值绑定到右边的名称。
>>> as_pattern("Inspired Python") Here is your string='Inspired Python' >>> as_pattern(42) You said a number=42
AS Patterns make it possible to bind grouped declarations
例如,如果没有 AS 模式,您只能将泛型数据绑定在一个模式中,而不能将其约束为一种类型。
护卫队
严格来说,护卫不算模式。在一个模式被匹配之后,但是在case
块内的代码被执行之前,它们被调用。
def greet_person(p): """Let's greet a person""" match p: # ... etc ... case str() as person if person.isupper(): print("No need to shout - I'm not deaf") case str() as person: print(f"Nice to meet you, {person}.")
这个greet_person
例子的特点是一个守卫。就像列表理解中可选的if
一样,你可以选择在case
块上附加一个守卫。如果您想根据绑定到模式中名称的值来做出决策,那么它们是很重要的。
在这个例子中,greet_person
函数检查一个人的名字是否是大写的,如果是,礼貌地要求他们不要大喊大叫。
因此,即使模式匹配,如果保护不正确,整个case
子句失败,并且match
语句继续下一个。
Guards let you evaluate the bound names from a pattern and apply additional checks
与模式的声明性质不同,保护中的表达式可能有副作用或其他复杂的逻辑,如下所示:
match json.loads(record): case {"user_id": user_id, "name": name} if not has_user(user_id): return create_user(user_id=user_id, name=name) case {"user_id": user_id}: return get_user(user_id) case _: raise ValueError('Record is invalid')
因此,您可以在应用程序中构建模式并应用从功能角度来看有意义的约束,而无需关心从数据结构中提取数据的细节。
或者模式
想要在单个case
语句中匹配两个或多个模式是一个常见的特性。多亏了 Python 的模式识别系统,你不再局限于单一模式。您可以在case
子句级别组合多个模式,也可以在单个模式中组合。尤其是后者,尤其强大。
一个重要的警告是,即使模式样式被正式命名为或模式,实际的语法要求您使用|
和而不是 or
。
1def or_pattern(p): 2 match p: 3 case ("Hello" | "Hi" | "Howdy") as greeting: 4 print(f"You said {greeting=}") 5 case { 6 "greeting": "Hi" | "Hello", 7 "name": ({"first_name": name} | {"name": name}), 8 }: 9 print(f"Salutations, {name}")
请注意,每一个突出显示的行都使用|
而从不使用or
。除了这种语法上的怪癖,所有东西的行为方式都与 Python 的其他部分非常相似。我特意在第 3 行的 OR 模式周围添加了括号,以确保as
语句使关系清晰,尽管这不是严格要求的。
或模式最强大的特性是能够将它们嵌套在您希望进行模式匹配的数据结构中。
让我们更仔细地分析第 5 & 6 行。
最顶层的模式是一个字典,要求名为"greeting"
的键必须存在。但是与我给出的第一个例子不同,这个例子期望"Hi" | "Hello"
作为"greeting"
值的子模式。所以"Hi"
或"Hello"
都是有效的问候。
第 6 行更具体一些。必须有一个键"name"
,并且它必须有一个以"first_name"
或"name"
为键的字典作为值。任何一个的值都绑定到名称name
。
*Sub-patterns are powerful and expressive
以声明方式描述我们想要的东西的好处再次显现出来。在您的应用程序中拥有一个漂亮整洁的数据结构(以及理解它的代码)并不少见,但是,像大多数事情一样,它会随着时间的推移而发展和变化。事实上,您仍然需要同时支持遗留格式和较新的格式。 OR Patterns 结合在现有模式中嵌入子模式的能力,使得它具有可读性、表达性,并且扩展和理解起来很简单。
When you bind a name in an OR Pattern it must be present in all OR patterns
注意,在第 6 行,我将键"first_name"
和"name"
的值绑定到了name
。不可能在一个或模式的一部分有一个绑定变量,而在另一部分没有。如果这是可能的,那将意味着一些有界变量将是未定义的,难以推理。
There are no equivalent AND patterns or NOT patterns
你只能得到或者模式。但这通常没问题;您可以约束您定义的模式来精确匹配您所需要的,这应该有望消除对而不是模式和以及模式的需要。
通配符模式
通常你想把和搭配起来,以表明你根本不在乎实际值,只是因为那里有和。在 Python 中,这个角色一直由_
担当:
_, name = ["Greetings", "Elaine"]
这是一种模式。您可能在一些示例的末尾看到过这种模式:
match p: # ... etc ... case _: # ... do something. ...
那是一个通配符符号,它匹配任何东西。因为您可以将整个 subject 表示为_
,所以当其他case
子句都不匹配时,它可以作为一个后备来匹配任何内容。
您也可以用它们来查询结构,例如,忽略列表中您不关心的元素:
def wildcardpattern(p): match p: case [_, middle, _]: print(middle)
模式[_, middle, _]
从正好三个元素的列表中提取倒数第二个元素。你不能引用通配符元素,因为它们是未绑定的;它们没有名称,也不能使用。任何在代码块中使用_
的尝试都将寻找实际的变量_
,如果这样的变量在作用域内的话。
但是,如果您愿意,您可以用as
指定一个通配符来绑定它:
def wildcardpattern(p): match p: case [_ as first, middle, _ as last]: print(middle)
但是这看起来相当迟钝,所以我建议您避免这样做,而是使用您自己选择的绑定名称。
您还可以使用*rest
语法来表示任意的元素序列,或者使用**kwargs
来表示关键字参数,如下所示:
def star_wildcard(p): match p: case [_, _, *rest]: print(rest) case {"name": _, **rest}: print(rest)
该模式返回*rest
,一个未知数量的元素序列,前提是它前面有两个匿名(通配符)元素:
>>> star_wildcard([1,2,3,4,5]) [3, 4, 5]
它的行为与您对字典的预期一样:
>>> star_wildcard({"name": "Cosmo", "age": 42, "last_name": "Kramer"}) {'age': 42, 'last_name': 'Kramer'}
尽管 Python 在推断列表或字典的结构方面相当聪明,但是一次不能有一个以上的*rest
或**kwargs
标记。所以如果你想要复杂的 Prolog 风格的有限关系和回溯,你需要自己做一些跑腿的工作。
Do not bind things you do not need
尽管您可以将大多数东西绑定到一个模式中,但是如果您不需要绑定,您应该避免这样做。通配符指示 Python 忽略该值,这样模式匹配器可以决定最有效的方式来返回您所关心的绑定名称。
如果您不关心绑定值,那么最好使用*_
和**_
而不是命名变量。
You can use wildcards in guards
因此,这是完全合理的,并且是一种有效的方式来约束一个模式,使其超出单独使用一个模式所能合理实现的范围:
match p: case [_, _, *rest] if sum(rest) > 10: print(rest)
价值模式
这可能是 Python 模式匹配实现中最有争议和争论的部分。
到目前为止,我写的所有东西都与静态模式有关。也就是说,我将它们输入到一个 Python 文件中,并且没有以任何方式在模式本身中包含从常量、变量或函数参数中导出的值。题材,没错,但不是图案。
回想一下,捕获模式是模式的值被绑定到名称的地方。
当您编写这样的代码时,问题就出现了:
PREFERRED_GREETING = "Hello" def value_pattern(p): match p: case {"greeting": PREFERRED_GREETING, "name": name} as d: print(d) case _: print("No match!")
它看起来很好,而且很有效。但是有一个问题。PREFERRED_GREETING
是一个绑定名称,它隐藏了同名的模块常量。
所以结果是:
>>> value_pattern({"greeting": "Salutations", "name": "Elaine"}) {'greeting': 'Salutations', 'name': 'Elaine'}
这不是我们想要的答案。遗漏"greeting"
键,它将完全不匹配:
>>> value_pattern({"name": "Elaine"}) No match!
原因是关于语法的一个未解决的争论。在典型的使用模式匹配的语言中,比如 LISP,你可以(这里简化一点)用引用或取消引用来表示它是(或者不是)一个变量或符号。
Python 没有这个功能。我承认,这是一个难以解决的问题,因为语法和符号会变得更加复杂,而且概念仅限于语言的这一特性。基本上,如果有一种方法可以将PREFERRED_GREETING
标记为值(也许是.PREFERRED_GREETING
或$PREFERRED_GREETING
——没关系)或者反过来,那么上面看到的问题就可以得到解决:每个捕获模式都可以与来自该模式之外的值明确区分开来。
使用值模式的唯一方法是将值放在 Python 可以推断出需要属性访问的地方。
import constants def value_pattern_working(p): match p: case {"greeting": constants.PREFERRED_GREETING, "name": name} as d: print(d) case _: print("No match!")
这是可行的,因为constants
是一个模块,而getattr(constants, 'PREFERRED_GREETING')
是属性访问的一个例子。另一种方法是将常量放入枚举中,如果可以的话,这是一种更好的方法。枚举是象征性的,它捕获了一个名字和一个值,当你把它和模式匹配结合起来时,它就是一场天作之合。
You cannot use plain variables, arguments or constants
Python 把它们和捕获模式混淆了,这是一个大混乱。在可能的情况下,您应该避免将值传递到模式匹配引擎中,除非您在属性查找之后对它们进行门控(例如,some_customer.user_id
而不是user_id
)
This is likely to be a source of bugs
小心行事,决定用一种标准的方式来表示您希望与模式匹配引擎共享的常量或变量值:
-
一个邓德类(
namedtuple
,dataclasses 等)。)来存放您希望使用的值 -
一个简单的包装类,它公开了一个属性,该属性具有您希望在模式中使用的值
-
如果可能的话,使用枚举
-
在一个模块中存储常量和其他模块级的东西,并显式地引用它,就像这样:
constants.MY_VALUE
序列模式
序列是列表和元组,或者从抽象基类collections.abc.Sequence
继承的任何东西。请注意,模式匹配引擎将而不是扩展任何类型的可重复项。
不像 Python 的其他部分那样,list("Hello")
是生成字符串字符列表的合法方式,这个用例确实适用于这里。字符串和字节被视为文字模式,而不被视为序列模式。
正如您现在所看到的,列表和元组按照您期望的方式运行。
You cannot represent sets in a pattern
可以在 subject 中使用它们,但是不能在case
子句中使用模式匹配或 set 构造。我建议你使用守卫来检查平等性,如果这是你想要做的。
映射(“字典”)模式
这里的映射意味着字典(或任何使用collections.abc.Mapping
的东西),到目前为止您也已经看到了如何做。当您对字典进行模式匹配时,需要注意的一点是,您在case
子句中指定的模式意味着针对主题的子集检查:
match {"a": 1, "b": 2}: case {"a": 1} as d: print(d)
case
子句匹配全长字典。如果你不想让这么做,你应该用一个守卫来执行:
match {"a": 1, "b": 2}: case {"a": 1, **rest} as d if not rest: print(d)
守卫检查字典的其余部分是否为空,如果为空,则只允许匹配。
Dictionary entries must exist when the pattern matching takes place
依靠defaultdict
创建元素作为模式匹配过程的副作用是行不通的,并且模式匹配尝试不会创建任何元素。匹配器使用对象的get(k)
方法将主题的键和值与映射模式进行匹配。
班级模式
匹配像字典和列表这样的基本结构是有用的,但是在较大的应用程序中,您通常会在复合对象中获取这些知识,并依靠封装来呈现数据的同构视图。
幸运的是,Python 3.10 可以处理大多数对象结构,或者不需要工作,或者只需要很少的工作。
from collections import namedtuple Customer = namedtuple("Customer", "name product") def read_customer(p): match p: case Customer(name=name, product=product): print(f"{name}, you must really like {product}.")
namedtuple
和dataclasses
都使用了模式匹配引擎。如上例所示,从对象中提取属性确实非常简单。
>>> read_customer(Customer(name="George", product="bosco")) George, you must really like bosco.
现在让我们来考虑一个反模式。也就是说,将导致副作用的复杂代码放在定制类的__init__
构造函数中:
class Connection: def connect(self): print(f"Connecting to server {self.host}") # ... do something complicated ... def __init__(self, host, port): self.host = host self.port = port self.connect() def parse_connection(p): match p: case Connection(host=host, port=port): print(f"This Connection object talks to {host}")
当你用一个给定的host
和port
创建一个Connection
的实例时,调用connect()
方法,作为一个演示,打印一条消息说它正在连接到主机。
>>> connection = Connection(host="example.com", port="80") Connecting to server example.com
>>> parse_connection(connection) This Connection object talks to example.com
请注意,Python 足够聪明,不会在模式匹配步骤中创建Connection
的实例。(如果它这样做了,我们会看到另一条“连接到服务器”的消息。)
因此,即使你的方法有副作用,也有一些安全措施来避免直接导致它们。
话虽如此,如果可能的话,你应该把这种逻辑转移到一个专门为你工作的类方法中。
摘要
唷。这是一个很大的特性,在本系列的第二部分中,除了您在这里看到的相当简单的例子之外,我将向您展示一些真实的用例。
这是一个很大的特性,有很多问题——特别是围绕捕获和价值模式——但是我认为好处远远大于坏处。Python 3.11 很可能也有一个解决这个问题的完美方案。
我相信结构模式匹配会减少错误。尤其是当您处理不完整的数据或需要转换的结构化数据时。即使您不是数据科学家或者不从事 ETL 工作,这也是我们都需要做的一件常见的事情,我确信它会在大多数 Python 开发人员的心中占据一席之地。
Pattern matching is declarative not imperative
你应该考虑你在一个case
子句中写的任何东西,以声明的方式表示数据的结构。在 Python 的其他地方,你没有能力限定你的数据结构是什么样子的(字典、命名元组或自定义对象等)。)而且还有选择性匹配和从数据中提取含义的能力。
从数据中转换和提取信息已经是一项艰巨的工作,但是 Python 的模式匹配库使它变得容易得多。
Beware Value and Capture Patterns
因为它们是同一的。不幸的是。我相信 Python 的未来版本会减弱这种尖锐的边缘,但是在这之前,您应该遵守我之前给出的建议,不要在没有首先在属性查找之后保护它的情况下将变量或常量传递给模式匹配引擎。
Pattern Matching encourages code without side effects
由于声明性的和(主要是!)Python 探查您编写的主题和模式的非侵入性方式。您应该考虑如何将这些概念应用到代码的其他部分。
如果您发现使用模式匹配引擎会在您的代码中产生副作用,那么我会花时间来思考您的代码是否做了正确的事情,如果您无法找到一种方法来做同样的工作。*
使用假设测试您的 Python 代码
Author Mickey Petersen
我可以想到几个 Python 包,它们极大地提高了我编写的软件的质量。其中两个是 pytest 和假说。前者添加了一个用于编写测试和夹具的人体工程学框架,以及一个功能丰富的测试运行程序。后者增加了基于属性的测试,可以使用聪明的算法找出除了最顽固的 bug 之外的所有 bug,这就是我们将在本课程中探索的包。
在一个普通的测试中,您通过生成一个或多个要测试的输入来与您想要测试的代码交互,然后验证它是否返回正确的答案。但是,这又提出了一个诱人的问题:你没有测试的所有输入怎么办?你的代码覆盖工具可能会报告 100%的测试覆盖率,但是这并不意味着代码没有 bug。
假设的一个定义特性是它能够以如下方式自动生成测试用例:
Reproducible
重复调用您的测试会产生可重复的结果,即使假设使用随机性来生成数据。
Methodical
你会得到一个详细的答案,解释你的测试是如何失败的以及为什么失败。假设清楚地表明了你,人类,如何重现导致你的测试失败的不变量。
Configurable
你可以改进它的策略,告诉它应该或不应该搜索哪里或什么。如果代码生成了无意义的数据,你没有必要为了迎合假设而修改代码。
所以让我们看看假设如何帮助你发现代码中的错误。
安装和使用假设
你可以通过输入pip install hypothesis
来安装假设。它几乎没有自己的依赖项,应该可以在任何地方安装和运行。
默认情况下,Hypothesis 会插入 pytest 和 unittest,因此您不必做任何事情就可以让它工作。此外,Hypothesis 附带了一个 CLI 工具,您可以使用hypothesis
调用它。但一会儿我们会详细讨论这个问题。
我将自始至终使用 pytest 来演示假设,但是它与内置的 unittest 模块一起工作也很好。
一个简单的例子
在我深入研究假说的细节之前,让我们从一个简单的例子开始:一个天真的 CSV 作者和读者。一个看起来很简单的话题:用逗号分隔数据字段,然后再读回来有多难?
但是,当然 CSV 是非常难做对的。美国和英国使用'.'
作为十进制分隔符,但在世界上的大部分地区,他们使用','
,这当然会导致立即失败。于是你开始引用事物,现在你需要一个状态机,可以区分引用的和未引用的;嵌套的引号呢,等等。
naive CSV reader 和 writer 是许多复杂项目的优秀替代者,这些复杂项目的需求表面上看起来很简单,但是存在大量您必须考虑的边缘情况。
def naive_write_csv_row(fields): return ",".join(f'"{field}"' for field in fields) def naive_read_csv_row(row): return [field[1:-1] for field in row.split(",")]
在这里,作者只需在用','
将每个字段连接在一起之前用引号将它们串起来。读者做相反的事情:它假设每个字段在被逗号分割后都被引用。
一个简单的往返 pytest 证明了代码“有效”:
def test_write_read_csv(): fields = ["Hello", "World"] formatted_row = naive_write_csv_row(fields) parsed_row = naive_read_csv_row(formatted_row) assert fields == parsed_row
显然如此:
$ pytest test.py::test_write_read_csv test.py::test_write_read_csv PASSED [100%]
对于许多代码来说,这是测试开始和结束的地方。几行代码来测试几个函数,这些函数以任何人都能阅读和理解的方式表现出来。现在让我们看看假设检验是什么样子的,以及当我们运行它时会发生什么:
import hypothesis.strategies as st from hypothesis import given @given(fields=st.lists(st.text(), min_size=1, max_size=10)) @example([","]) def test_read_write_csv_hypothesis(fields): formatted_row = naive_write_csv_row(fields) parsed_row = naive_read_csv_row(formatted_row) assert fields == parsed_row
乍一看,这里没有什么是你猜不到的,即使你不知道假设。我要求参数fields
有一个从生成文本的一个元素到十个元素的列表。除此之外,测试的运行方式与之前完全相同。
现在看看我运行测试时会发生什么:
$ pytest test.py::test_read_write_csv_hypothesis E AssertionError: assert [','] == ['', ''] test.py:44: AssertionError ----- Hypothesis ---- Falsifying example: test_read_write_csv_hypothesis( fields=[','], ) FAILED test.py::test_read_write_csv_hypothesis - AssertionError: assert [','] == ['', '']
假说很快找到了一个破坏我们代码的例子。事实证明,[',']
列表破坏了我们的代码。在通过 CSV 编写器和读取器来回传递代码之后,我们得到了两个字段——发现了我们的第一个 bug。
简而言之,这就是假设的作用。但是我们来详细看一下。
理解假说
使用假设策略
简而言之,假设使用许多可配置的策略生成数据。策略从简单到复杂。一个简单的策略可能会产生布尔;另一个整数。您可以组合策略来制作更大的列表,例如匹配您想要测试的特定模式或结构的列表或字典。您可以基于某些约束来限制它们的输出,比如只有正整数或特定长度的字符串。如果有特别复杂的需求,也可以自己写策略。
策略是进入基于属性的测试(??)的大门,也是假设如何工作的基本部分。您可以在其文档的策略参考或hypothesis.strategies
模块中找到所有策略的详细列表。
感受每种策略在实践中的作用的最佳方式是从hypothesis.strategies
模块导入它们,并在实例上调用example()
方法:
>>> import hypothesis.strategies as st >>> st.integers().example() 14633 >>> st.lists(st.floats(), min_size=5).example() [-3.402823466e+38, inf, -1.7976931348623157e+308, 3.330825410893303e+16, -2.2250738585072014e-308]
您可能已经注意到,floats 示例在列表中包含了inf
。默认情况下,所有的策略都会——在可行的情况下——尝试测试你能产生的所有合法的(但可能是模糊的)形式的价值。这一点尤其重要,因为像inf
或NaN
这样的极限情况是合法的浮点值,但是,我想,你通常不会对自己进行测试。
这也是假说试图在你的代码中发现错误的一个支柱:通过测试你自己可能会忽略的边缘情况。如果你问它一个text()
策略,你很可能得到西方字符,因为你是一个 unicode 和转义编码垃圾的大杂烩。理解为什么假设会产生它所产生的例子,这是一种思考你的代码如何与它无法控制的数据交互的有用方法。
现在,如果它只是从无穷无尽的数字或字符串来源中生成文本或数字,它就不会像实际上的和一样捕捉到那么多错误。原因是你写的每一个测试都受到一系列从你设计的策略中抽取的例子的影响。如果一个测试用例失败了,它会被放在一边再次测试,但是如果可能的话,会减少输入的子集。在假设中,这被称为缩小搜索空间,试图找到可能导致代码失败的最小结果。因此,如果它能找到一个只有 3 或 4 的字符串,而不是 10,000 长度的字符串,它会尝试向您显示。
过滤和映射策略
如果策略不符合您的要求,您可以将假设告诉filter
或map
它所举的例子,以进一步减少假设:
>>> st.integers().filter(lambda num: num > 0 and num % 8 == 0).example() 20040
这里我要求的是数字大于 0 且能被 8 整除的整数。然后,假设将尝试生成满足您对其施加的约束的示例。
你也可以map
,它的工作方式和 filter 差不多。这里我要求小写 ASCII,然后大写:
>>> st.text(alphabet=string.ascii_lowercase, min_size=5).map(lambda x: x.upper()).example() 'RDMBYRRONWQRZWHREEH'
话虽如此,当您没有to(我可以要求以大写 ASCII 字符开始)时,使用任一种都可能导致较慢的策略。
第三个选项flatmap
,让你从策略中建立策略;但这值得更仔细的研究,所以我稍后会谈到它。
写作策略
您可以通过用|
或st.one_of()
组合策略来告诉假设从的多个策略中选择:
>>> st.lists(st.none() | st.floats(), min_size=3).example() [2.00001, None, 1.1754943508222875e-38]
当您必须从多个来源的示例中提取单个数据点时,这是一个基本特性。
约束和可满足性
当你让假设举一个例子时,它会考虑你可能对它施加的约束:只有正整数;只有加起来正好是 100 的数字列表;您可能申请的任何filter()
通话;诸如此类。这些都是制约因素。你拿走了曾经无限的(也就是说,相对于你从中得出例子的策略)的东西,并引入了额外的限制,约束了它能给你的可能的价值范围。
但是考虑一下,如果我通过了什么都不会产生的过滤器会发生什么:
>>> st.integers().filter(lambda num: num > 0).filter(lambda num: num < 0).example() Unsatisfiable: Unable to satisfy assumptions of example_generating_inner_function
在某一点上,假设将放弃,并宣布它无法找到任何满足该策略及其约束的东西。
Make sure your strategies are satisfiable
假设过一会儿就放弃了,如果它不能举出一个例子的话。通常这表明你设置的约束中有一个不变量,使得很难或不可能从中得出例子。在上面的例子中,我要求同时小于零和大于零的数字,这是一个不可能的要求。
用函数编写可重用策略
如您所见,策略是简单的函数,它们的行为也是如此。因此,您可以将每个策略重构为可重用的模式:
import string def generate_westernized_name(min_size=2): return (st.text(alphabet=string.ascii_letters, min_size=min_size) .map(lambda name: name.capitalize())) @given(first_name=generate_westernized_name(min_size=5)) def test_create_customer(first_name): # ... etc ...
这种方法的好处是,如果您发现假设没有考虑到的边缘情况,您可以在一个地方更新模式,并观察它对代码的影响。它既实用又可组合。
这种方法的一个警告是,你不能画出例子,并期望假设行为正确。所以我不建议你在一个策略上调用example()
只是为了把它传递给另一个策略。
为此,你需要一个@composite
装饰师。
陈述策略
如果前面的方法在本质上是毫不掩饰的功能性的,那么这种方法就是必须的。
@composite
decorator 让您编写命令性的 Python 代码。如果您不能用内置的策略轻松构建您的策略,或者如果您需要对它发出的值进行更细粒度的控制,您应该考虑@composite
策略。
不要像上面那样返回一个复合策略对象,而是使用一个特殊的函数来绘制示例,您可以在修饰函数中访问这个函数。
from hypothesis.strategies import composite @composite def generate_full_name(draw): first_name = draw(generate_westernized_name()) last_name = draw(generate_westernized_name()) return (last_name, first_name)
此示例绘制两个随机化的名称,并将它们作为元组返回:
>>> generate_full_name().example() ('Mbvn', 'Wfyybmlc')
请注意,@composite
装饰器传入了一个特殊的draw
可调用函数,您必须用它来绘制样本。你不能——嗯,你可以,但是你不应该——在你得到的策略对象上使用example()
方法。这样做将破坏假设正确合成测试用例的能力。
因为代码是命令式的,你可以根据自己的喜好随意修改绘制的示例。但是如果给你一个你不喜欢的例子,或者一个打破了你不想测试的已知不变量的例子呢?为此,您可以使用assume()
函数来陈述假设必须满足的假设,如果您试图从generate_full_name
中提取一个例子的话。
假设first_name
和last_name
一定不相等:
from hypothesis import assume @composite def generate_full_name(draw): first_name = draw(generate_westernized_name()) last_name = draw(generate_westernized_name()) assume(first_name != last_name) return (last_name, first_name)
像 Python 中的assert
语句一样,assume()
函数教导假设什么是有效的例子,什么不是。你可以用它来产生复杂的复合策略。
如果你用@composite
编写命令式策略,我建议你遵循以下经验法则:
Avoid filtering drawn examples yourself
如果你想画一系列的例子来初始化,比方说,一个列表或者一个自定义对象,它们的值满足一定的标准,你应该使用filter
,在可能的情况下,使用assume
来教导假设为什么你画的值并没有任何好处。
上面的例子使用assume()
来教导假设first_name
和last_name
一定不相等。
Separate functional and non-functional strategies
如果你能把你的功能策略放在不同的功能中,你应该这样做。它鼓励代码重用,如果您的策略失败了(或者没有生成您期望的那种示例),您可以依次检查每个策略。大型嵌套策略更难解开,更难推理。
Only write @composite
strategies if you must
如果你能用filter
和map
或者内置的约束(比如min_size
或者max_size
)来表达你的需求,你应该这样做。使用assume
的命令式策略可能需要更多的时间来集中在一个有效的例子上。
@example
:明确测试某些值
偶尔,您会遇到一些失败或曾经失败的案例,您希望确保假设没有忘记测试它们,或者向您自己或您的开发伙伴表明某些值会导致问题,应该明确地进行测试。
装饰者就是这么做的:
from hypothesis import example @given(fields=st.lists(st.text(), min_size=1, max_size=10)) @example([","]) def test_read_write_csv_hypothesis(fields): # ... etc ...
你想加多少就加多少。
假设示例:罗马数字转换器
假设我想写一个简单的罗马数字转换程序。
SYMBOLS = { "I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000, } def to_roman(number: int): numerals = [] while number >= 1: for symbol, value in SYMBOLS.items(): if value <= number: numerals.append(symbol) number -= value break return "".join(numerals) def test_to_roman_numeral_simple(number): numeral = to_roman(number) assert set(numeral) and set(numeral) <= set(SYMBOLS.keys())
在这里,我将罗马数字收集到numerals
中,一次一个,通过循环有效数字的SYMBOLS
,从number
中减去符号的值,直到 while 循环的条件(number >= 1
)为False
。
该测试也很简单,并作为一个烟雾测试。我生成一个随机整数,用to_roman
转换成罗马数字。当该说的都说了,该做的都做了,我把数字串变成一个集合,并检查集合中的所有成员都是合法的罗马数字。
现在,如果我运行 pytest,它似乎会挂起。但由于假设的调试模式,我可以检查为什么:
$ pytest -s --hypothesis-verbosity=debug test_roman.py::test_to_roman_numeral_simple
Trying example: test_to_roman_numeral_simple(
number=4870449131586142254,
)
啊。它没有像人类通常做的那样用很小的数字进行测试,而是用了一个非常大的数字……非常慢。
好了,至少有一个问题。这其实并不是一个 bug ,但却是需要考虑的事情:限制最大值。我只打算限制这个测试,但是在代码中限制它也是合理的。
将max_value
更改为合理的值,比如st.integers(max_value=5000)
,测试失败,并出现另一个错误:
$ pytest test_roman.py::test_to_roman_numeral_simple
Falsifying example: test_to_roman_numeral_simple(
number=0,
)
似乎我们的代码不能处理数字 0!哪个…是正确的。罗马人并没有像我们今天这样真正使用数字零;那项发明是后来才出现的,所以他们有一堆变通办法来处理某些东西的缺失。但在我们的例子中,这并不重要。让我们也设置min_value=1
,因为也不支持负数:
$ pytest test_roman.py::test_to_roman_numeral_simple
1 passed in 0.09s
好吧…还不错。我们已经证明,在我们定义的数值范围内给定一个随机的数字组合,我们确实会得到类似于罗马数字的东西。
关于假设的最困难的事情之一是以一种测试其属性的方式将问题框定到你的可测试代码中,但是没有你,开发者,预先(必然地)知道答案。因此,测试我们的to_roman
函数是否至少有些半相干的东西的一个简单方法是检查它是否能生成我们之前在SYMBOLS
中定义的数字:
@given(numeral_value=st.sampled_from(tuple(SYMBOLS.items()))) def test_to_roman_numeral_sampled(numeral_value): numeral, value = numeral_value assert to_roman(value) == numeral
在这里,我从前面的SYMBOLS
元组中的采样。采样算法将决定它想给我们什么值,我们所关心的是给我们像("I", 1)
或("V", 5)
这样的例子来比较。
所以让我们再次运行 pytest:
$ pytest test_roman.py
Falsifying example: test_to_roman_numeral_sampled(
numeral_value=('V', 5),
)
FAILED test.py::test_to_roman_numeral_sampled -
AssertionError: assert 'IIIII' == 'V'
哎呀。罗马数字V
等于5
,然而我们得到了五个IIIII
?更仔细的研究发现,事实上,代码只有产生的序列I
等于我们传递给它的数。我们的代码中有一个逻辑错误。
在上面的例子中,我遍历了SYMBOLS
字典中的元素,但是因为它是有序的,所以第一个元素总是I
。由于最小的可表示值是 1,我们最终得到了这个答案。从技术上来说,是正确的,因为你可以只用I
来计数,但它不是很有用。
尽管修复它很容易:
import operator def to_roman(number: int): numerals = [] g = operator.itemgetter(1) ordered_numerals = sorted(SYMBOLS.items(), key=g, reverse=True) while number >= 1: for symbol, value in ordered_numerals: if value <= number: number -= value numerals.append(symbol) break return "".join(numerals)
重新运行测试会产生一个通过。现在我们知道,至少我们的to_roman
函数能够映射等于SYMBOLS
中任何符号的数字。
现在,试金石是接受给我们的数字,并理解它。因此,让我们编写一个将罗马数字转换回十进制的函数:
def from_roman(numeral: str): carry = 0 numerals = list(numeral) while numerals: symbol = numerals.pop(0) value = SYMBOLS[symbol] carry += value return carry @given(number=st.integers(min_value=1, max_value=5000)) def test_roman_numeral(number): numeral = to_roman(number) value = from_roman(numeral) assert number == value
像to_roman
一样,我们遍历每个字符,获得数字的数值,并将其添加到运行总数中。该测试是一个简单的往返测试,因为to_roman
有一个反函数 from_roman
(反之亦然),因此 :
assert to_roman(from_roman('V')) == 'V' assert from_roman(to_roman(5)) == 5
By the way …
可逆函数更容易测试,因为您可以将一个函数的输出与另一个函数的输入进行比较,并检查它是否产生原始值。但是,并不是每个函数都有反函数。
运行测试会产生一个通过:
$ pytest test_roman.py::test_roman_numeral
1 passed in 0.09s
所以现在我们处于一个非常好的位置。但是在我们的罗马数字转换器中有一个小小的疏忽:它们不尊重一些数字的减法规则。例如VI
为 6;但是IV
是 4。值XI
是 11;而IX
是 9。只有一些(叹气)数字展现了这种属性。
所以我们再写一个测试。这一次它会失败,因为我们还没有编写修改后的代码。幸运的是,我们知道我们必须适应的减法数字:
SUBTRACTIVE_SYMBOLS = { "IV": 4, "IX": 9, "XL": 40, "XC": 90, "CD": 400, "CM": 900, } @given(numeral_value=st.sampled_from(tuple(SUBTRACTIVE_SYMBOLS.items()))) def test_roman_subtractive_rule(numeral_value): numeral, value = numeral_value assert from_roman(numeral) == value assert to_roman(value) == numeral
很简单的测试。检查某些数字是否产生值,以及这些值是否产生正确的数字。
有了一个广泛的测试套件,我们应该有信心对代码进行修改。如果我们弄坏了什么东西,我们之前的测试就会失败。
def from_roman(numeral: str): carry = 0 numerals = list(numeral) while numerals: symbol = numerals.pop(0) value = SYMBOLS[symbol] try: value = SUBTRACTIVE_SYMBOLS[symbol + numerals[0]] numerals.pop(0) except (IndexError, KeyError): pass carry += value return carry
数字减去 ?? 的规则是相当主观的。字典里有最常见的。所以我们需要做的就是在数目字列表前面读一下,看看是否存在一个两位数的数字,它有一个规定的值,然后我们用它来代替通常的值。
1def to_roman(number: int): 2 numerals = [] 3 g = operator.itemgetter(1) 4 ordered_numerals = sorted( 5 (SYMBOLS | SUBTRACTIVE_SYMBOLS).items(), 6 key=g, 7 reverse=True, 8 ) 9 while number >= 1: 10 for symbol, value in ordered_numerals: 11 if value <= number: 12 numerals.append(symbol) 13 number -= value 14 break 15 return "".join(numerals)
变化很简单。两个数字符号字典的结合就是 。代码已经知道如何将数字转化为数字——我们只是增加了一些。
By the way …
此方法需要 Python 3.9 或更高版本。阅读 如何合并字典
如果操作正确,运行测试应该会通过:
$ pytest test_roman.py
5 passed in 0.15s
仅此而已。我们现在有了有用的测试和一个功能性的罗马数字转换器,可以轻松地进行转换。但是我们没有做的一件事是创建一个使用st.text()
生成罗马数字的策略。生成有效和无效罗马数字以测试转换器耐用性的自定义组合策略留给您作为练习。
在本课程的下一部分,我们将探讨更高级的测试策略。
摘要
Hypothesis is a capable test generator
不像faker
这样的工具为设备或演示生成逼真的测试数据,假设是一个基于属性的测试器。它使用试探法和聪明的算法来寻找破坏你的代码的输入。
Hypothesis assumes you understand the problem domain you want to model
测试一个没有反函数来比较结果的函数——就像我们双向工作的罗马数字转换器——你经常不得不把你的代码当作一个黑盒来处理,在那里你放弃对输入和输出的控制。这更难,但会使代码不那么脆弱。
Hypothesis augments your existing test suite
混合搭配测试完全没问题。假设对于清除你永远不会想到的不变量是有用的。将它与已知的输入和输出结合起来,开始前 80%的测试,并增加假设来抓住剩下的 20%。
塔防游戏:游戏模式和碰撞检测
Author Mickey Petersen
我们已经为投射物和敌人找到了路径和运动。现在是考虑碰撞检测的时候了。我们的炮塔需要一种方法来检测他们视线范围内的敌人,我们希望我们的飞行弹丸能够检测到他们何时击中了敌人。
塔防游戏:寻路
原文:https://www.inspiredpython.com/course/create-tower-defense-game/tower-defense-game-path-finding
Author Mickey Petersen
与其让关卡设计师画出敌人行走的路径,不如用图论和一点递归自动生成一条路径。我们想要一种算法,如果有不止一个入口或出口,也能生成所有合法的路径组合。
塔防游戏:动画和运动学
Author Mickey Petersen
一个塔防游戏需要移动:敌人应该缓慢地穿过战场,我们的炮塔的视野应该充满威胁地扫过,寻找可以发射炮弹的目标。
同时,我们也需要考虑路径寻找和动画。实际上,它们都是相关的,因为它是关于运动——运动学。
所有这些事情都发生在几秒钟和几分钟内,我们必须将它们封装到一个系统中,该系统与我们的游戏循环和我们想要的帧速率一起工作。有很多方法可以做到这一点,比如用累加器变量来保存状态的循环。但是还有另一种方法:用itertools
、生成器和懒惰评估。
塔防游戏:磁贴引擎和地图编辑器
Author Mickey Petersen
我们的塔防游戏需要一个简单的类似网格的系统来放置某些精灵,比如道路,因为这样可以更容易地在以后建立一个路径查找算法,这样敌人就可以从头到尾找到他们的路。
尽管网格很有用,但我们不会仅限于此;灌木、树木和其他图形装饰根本不需要网格,所以无论我们建造什么,它都应该适用。
塔防游戏:处理精灵
原文:https://www.inspiredpython.com/course/create-tower-defense-game/tower-defense-game-handling-sprites
Author Mickey Petersen
精灵是你在屏幕上绘制的实体——通常是图形。在我们的塔防游戏中,我们需要很多这样的东西:投射物、敌人、构成游戏区域的植物以及 HUD 的文本。
更复杂的是,它们都需要自己独特的功能来使我们的游戏感觉完整和专业:我们的弹丸在空中飞行,所以它们需要移动和旋转。当它们在撞击时爆炸,它们需要分裂成碎片,所以我们需要考虑动画和碰撞检测。我们的敌人需要移动和死亡动画,他们应该沿着从产卵到逃跑点的路径平稳地行走。
是时候让我们看看如何在 PyGame 中建造精灵了。
塔防游戏:基本游戏模板
Author Mickey Petersen
是时候开始处理事件并把东西画到屏幕上了。基于之前的状态机和控制器层,我们需要一个简单的抽象来表示我们需要的每个屏幕。幸运的是,通过类继承和一点前瞻性的思考,有一个简单的方法可以做到这一点。
塔防游戏:有限状态自动机/状态机
Author Mickey Petersen
复杂的代码库——游戏通常也很复杂——往往依赖于大量的状态,通常在变量中捕获。从游戏中的一个屏幕导航到另一个屏幕涉及到很多变化:你需要渲染不同的东西;您使用的键绑定也可能会改变;也许你需要清除旧的对象,比如当你从游戏切换到比分屏幕时。
但是没有不断增加的变量来表示你的代码应该做什么——比如,is_in_menu
,has_won_game
,is_in_level_editor
等等。–你应该考虑使用有限状态自动机,或者通常称为状态机,形式化你的有状态代码。
为了提高我们代码的可扩展性,是时候考虑我们如何有效地使用简单的状态机来表示游戏的状态,以及 OOP 和继承如何帮助关注点的分离。
什么是有状态?
所有有用的计算机程序都捕捉某种形式的状态 ??。在一个玩具应用程序中,询问你的名字并重复它,它可能是一个名为name
的变量,使我们的应用程序有状态——也就是说,我们的程序正在存储,并可以随意调用我们显式给它的信息,或者它通过从文件或数据库中读取的东西隐式生成的信息。不管如何,我们通常将它存储在变量中,或者更一般地,存储在内存的某个地方,不管我们可能选择使用什么样的松散的内存定义。
那么,为什么有状态很重要呢?因为在简单的变量中捕捉重要的业务逻辑,并在此基础上添加一层又一层的信息实在是太容易了。
到处都是布尔人
考虑尝试编写一个有几个屏幕的简单游戏,以及如何跟踪它在哪个屏幕上:
-
当你开始游戏时第一次遇到的主菜单,或者当你退出游戏或地图编辑器时退出。
-
你玩真正游戏的主游戏
-
一个记录你的分数和表现的输赢屏幕
-
关卡编辑器屏幕
-
配置/选项屏幕
诸如此类。
天真地,你可以用无数的布尔值来存储你的游戏当前应该向用户显示的内容:
in_main_menu = True in_game_playing = False in_score_screen = False in_level_editor = False in_options_screen = False # ... etc ...
所以,每次你从一个屏幕(和游戏的功能部分)切换到另一个,你必须记得更新所有的布尔标志。
如果你不小心将两个设为True
,你的游戏很可能会以尴尬的方式中断。你可以在屏幕上呈现两种不同的东西。此外,您可能有一组路径,用户必须通过这些路径才能到达某些屏幕;例如,从主菜单转到乐谱屏幕通常是不可能的。
这并不是说布尔方法不好。布尔变量非常有用,演示程序很好地利用了它们。
但是有一种更容易理解的方式来捕捉游戏的状态。
有限状态自动机/状态机
有限状态自动机只是计算机科学中计算模型的一个方面。我们不会深入讨论这个问题,因为对于大多数人来说,状态机的概念是非常直观的,尤其是对于程序员来说,他们经常在不知道这是一门正式学科的情况下创建状态机。
An example of the possible transitions our game is able to make. Each box represents a state and the directed arrows the possible paths.
你可能对流程图很熟悉,比如包含的例子。这是一个国家机器。从圆形开始,您可以按照箭头的方向从一个方块过渡到下一个方块。该示例与演示中使用的状态机非常相似。
它还显示了您可以或不可以转换到的位置和内容。不过,对于这种没有图表的直观游戏,对于非常大的游戏或应用程序,你会希望有一个工具能够将它们绘制到屏幕上。
在我们的情况下,我们可以保持简单。在演示的大部分地方,我不强制执行合法转换,除了对防止崩溃或其他严重问题至关重要的地方,但是在更大的应用程序中,您肯定希望这样做!
考虑一个电子商务网站。您需要确保在电子商务系统更新到ship_merchandise
之前,客户首先经历has_paid
状态!应用程序中的大量逻辑错误可直接归因于这种错误。
你可以用多种方式代表州。我认为用 Python 表示机器状态最简单的方法是使用来自enum
模块的Enum
类。
枚举类
import enum class GameState(enum.Enum): """ Enum for the Game's State Machine. Every state represents a known game state for the game engine. """ # Unknown state, indicating possible error or misconfiguration. unknown = "unknown" # The state the game engine would rightly be set to before # anything is initialized or configured. initializing = "initializing" # The game engine is initialized: pygame is configured, the sprite # images are loaded, etc. initialized = "initialized" # The game engine is in map editing mode map_editing = "map_editing" # The game engine is in game playing mode game_playing = "game_playing" # The game engine is in the main menu main_menu = "main_menu" # The game engine is rendering the game ended screen. game_ended = "game_ended" # The game engine is exiting and is unwinding quitting = "quitting" class StateError(Exception): """ Raised if the game is in an unexpected game state at a point where we expect it to be in a different state. For instance, to start the game loop we must be initialized. """
您可能想跳过这一步,只使用字符串。避免这种诱惑。字符串是有用的,它们完全能够表示对应用程序重要的信息,但是它们缺少枚举的一些特性:
Enums record a name and associated value for each member
枚举类就像常规类一样。您可以编写文档字符串并添加方法。每个枚举还记录名称(左侧)及其组成值(右侧)
每个枚举元素都记得它的name
和value
属性:
>>> GameState.starting <GameState.starting: 'starting'>
>>> GameState.starting.value 'starting'
>>> GameState.starting.name 'starting'
它们知道有效和无效的名称,您可以从它的值创建一个枚举元素:
>>> GameState("bad name") ValueError: 'bad name' is not a valid GameState
>>> GameState("game_ended") <GameState.game_ended: 'game_ended'>
Enums are typed, and aid with code completion and type hinting
因此,如果你声明一个变量或参数为GameState
,你的编辑器将帮助你完成代码。
Enums are iterable and support membership testing
因此,您可以在循环中使用它们来捕捉所有元素:
>>> list(GameState) [<GameState.unknown: 'unknown'>, <GameState.starting: 'starting'>, <GameState.initialized: 'initialized'>, <GameState.map_editing: 'map_editing'>, <GameState.game_playing: 'game_playing'>, <GameState.main_menu: 'main_menu'>, <GameState.game_ended: 'game_ended'>, <GameState.quitting: 'quitting'>]
并检查成员资格:
>>> GameState.map_editing in GameState True
Enums are symbolic
这是需要理解的最重要的事情。枚举代表一个符号——在GameState
中,每个值都是一个字符串,但它也可以是一个 int 或其他原始类型。但是通常精确的值并不重要,重要的是它在任何地方都是相同的和一致的。这真的是要记住的事情。你传递的不是一个字符串,或者一个整数,而是一个符号(GameState
),带有name
和,是的,一个value
。
这意味着您想要完美完成的所有常规条件检查。如果你使用IntEnum
,你将获得枚举元素行为类似数字的优势,这意味着>
、<
等等也可以工作。
本课程将大量使用枚举来表示具有象征意义的事物。
把所有的放在一起
现在我们可以利用我们新发现的知识,从上一章开始,用一个基本的状态机来扩展TowerGame
。
@dataclass class TowerGame: ... state: GameState ... @classmethod def create(cls, ...): return cls(..., state=GameState.initializing) def set_state(self, new_state): self.state = new_state def assert_state_is(self, *expected_states: GameState): """ Asserts that the game engine is one of `expected_states`. If that assertions fails, raise `StateError`. """ if not self.state in expected_states: raise StateError( f"Expected the game state to be one of {expected_states} not {self.state}" ) def start_game(self): self.assert_state_is(GameState.initialized) self.set_state(GameState.main_menu) self.loop() def loop(self): while self.state != GameState.quitting: if self.state == GameState.main_menu: # pass control to the game menu's loop elif self.state == GameState.map_editing: # ... etc ... elif self.state == GameState.game_playing: # ... etc ... self.quit() def init(self): self.assert_state_is(GameState.initializing) ... self.set_state(GameState.initialized)
这里我添加了几个助手函数来帮助处理状态转换。我们现在可以断言,无论何时,游戏状态是一个或多个已知状态。如果我们在调用init
之前调用它,那么start_game
就会出错。而init
本身如果不在GameState.initializing
状态就不会运行。
这意味着我们现在终于也可以写出一些主要的loop
代码了:只要我们不处于quitting
状态,它的 while 循环就会一直循环下去。目前,循环将检查我们是否处于许多游戏状态中的一个,并且——虽然还没有写出来,因为我们还没有到那一步——将控制权交给我们游戏代码的另一部分。
为什么会这样?因为这个loop
是一个控制器。它的目标不是在屏幕上做任何繁重的绘图工作,也不应该处理键盘输入本身。你绝对可以用让做到这一点:你有一个screen
可以利用,你还可以监听键盘和鼠标事件。那么为什么不这样做呢?嗯:
Every game state represents vastly different requirements
考虑一个主菜单。我们希望——因为我们在一定程度上接近演示——一个菜单项,可能还有一些花哨的图形效果和一个标志来展示我们的酷游戏。但那…不是我们想要的地图编辑器。其实和主菜单完全不一样。
那么你将如何处理两个非常矛盾的需求呢?用if
语句。很多人。别忘了,你想要绘制到屏幕上的每一个对象、精灵或资源都需要从TowerGame
中访问。因此,您最终会得到一个level
属性来存储地图编辑器的级别细节;一个menu_group
用于当你在主菜单中时呈现的菜单项;游戏本身的score
和hud
。
你最终会得到几十种不同的东西,这些东西只在某些情况下适用,而且只在某些游戏状态下适用。
我们将创建一个类结构来代表我们在屏幕上绘制东西所需要的一切,而不是将我们自己提交给一个混乱的开发体验;处理键盘和鼠标输入;以此类推,并在一个漂亮,干净,易于理解的结构。
演示的状态机是基本的,但是如果您愿意,您可以做一些事情来改进它:
Enforcing only legitimate transitions
目前,你可以用你喜欢的任何状态调用set_state
,即使这样做没有意义。就像在已经是initialized
之后又回到了initializing
。您可以扩展它来检查和强制执行只有从当前状态的有效状态转换是可能的。这样,如果你把它发送到错误的状态,你的游戏就会出错。这是在较大的代码库中捕捉严重错误的有效方法。
Separating the state machine into a new class
不要把它集成到TowerGame
中,你可以创建一个独立的类,它也接受一个状态枚举类作为它的转换源,而不是硬编码,就像我在TowerGame
中做的那样。
后续步骤
我们有一个骨架类可以初始化我们的游戏,我们有一个状态机能够跟踪它的当前状态,并强制它在继续之前必须处于特定的状态。
如果你愿意,那里还有很多事情要做,但这已经足够让我们开始了。
现在我们需要构建一个简单的类结构,让我们能够表现一个游戏屏幕——从而表现一个独特的游戏状态——以及我们为什么要这么做。过了那个,就该开始构建游戏了!
摘要
State Machines are useful abstractions
它们对于需要排序的事情非常有用——例如,首先初始化,然后显示主菜单——但它们不是万能的。有时一两个布尔数更容易推理;你可能最终会有太多的州什么都不做,或者过于分散。
这是一个平衡的行为,所以把状态机想象成你工具箱中的另一个工具。
Enums represent symbolic values
它们有一个实际值和一个 Python 友好的属性名,但是它们的主要目的是消除函数之间传递的内容的任何模糊性。字符串很快就会失去上下文,如果你改变了它们,就需要小心翼翼地更新。
塔防游戏:游戏循环和初始化 PyGame
Author Mickey Petersen
你如何指导你的电脑游戏持续地在屏幕上画东西,并伴随着鼓点,确保没有尴尬的停顿或抖动?倾听键盘和鼠标的输入,或者更新你的游戏的比分板呢?
做错其中任何一件事,或者忘记去做,你的游戏就会出问题。更糟糕的是,它可能会以你在自己的机器上不一定会发现的方式出错。
这就是为什么所有的电脑游戏——无论大小——都有一个(或者更多!)游戏循环确保游戏以可重复且稳定的方式执行其最重要的任务。
是时候编写我们的骷髅游戏循环了,它将在我们塔防游戏的整个开发过程中为我们服务。
PyGame
如果你以前从未使用过 Pygame,有几件事你应该知道。Pygame 是我所说的“低级”库。它的主要目的是提供一个画布——字面意思是——你可以在上面画画,有各种各样有用的游戏开发原语和助手来帮助你构建。
开箱后,您将获得:
-
简单的雪碧 管理
-
用于简单碰撞检测的原语
-
基本混音
-
感谢 SDL 图书馆的软件或硬件加速的 2d 画布绘制
-
向量和仿射变换(如旋转和缩放)
-
键盘/鼠标和事件处理
-
许多绘图图元,如圆形、直线、矩形等。
By the way …
Sprite 是一个过时的术语,指绘制到屏幕上的 2d 图像。20 世纪 80 年代和 90 年代的游戏机通常都配备了针对精灵绘制和处理进行优化的硬件。
差不多就是这样。起初,这似乎不是一件好事,但对于 Python 开发人员来说,绝对是!
你将会学到更多用这些原语构建游戏的知识,并且,正如你将会看到的,一旦理解了基本概念,你就可以用这些原语做很多很酷的事情。
正在初始化 PyGame
初始化 PyGame 将遵循我们游戏的所有其他方面的相同初始化,看起来应该有点像这样:
-
游戏开始了
-
PyGame 被设置和初始化
-
我们需要的资产已经装载完毕
-
游戏进入下一个任务,不管是什么,比如显示菜单或介绍
然而,这里重要的是,我们想做点 2 和 3 一次。当 PyGame 初始化时,它必须在后台做大量的内务处理,它绝对不希望被初始化超过一次——如果你这样做的话,就等着崩溃和错误吧!
我们还应该确保只加载一次资产;正如你将会看到的,我们希望保持一个高的帧速率并且加载资产是一点也不快。
事实上,这也适用于许多其他事情:如果我们只需要做一次,我们应该努力不要做超过一次。听起来显而易见,但很容易错过一些东西,而且出错会影响游戏的帧率。
帧速率——以每秒帧来衡量——是我们更新屏幕的速率。60 FPS 是本教程的目标,但并不是所有的游戏都需要这么高(甚至更高)的帧速率。
让我们从导入 PyGame 开始:
>>> import pygame
现在初始化 PyGame。
def init(screen_rect: pygame.Rect, fullscreen: bool = False): pygame.init() window_style = pygame.FULLSCREEN if fullscreen else 0 # We want 32 bits of color depth bit_depth = pygame.display.mode_ok(screen_rect.size, window_style, 32) screen = pygame.display.set_mode(screen_rect.size, window_style, bit_depth) pygame.mixer.pre_init( frequency=44100, size=32, # N.B.: 2 here means stereo, not the number of channels to use # in the mixer channels=2, buffer=512, ) pygame.font.init() return screen
这个函数完成了相当多的工作,所以让我们来看看每个部分:
-
每个游戏都占据一定的屏幕大小——这就是
screen_rect
的用途。正如你马上会看到的,我稍后会将它定义为我们的图形块大小的倍数。 -
如果我们指定那个函数参数,我们可以在
fullscreen
模式下运行游戏 -
我们坚持 32 位色深。这样,计算机使用的三种颜色通道:红色、绿色和蓝色,每种通道有 8 位。另一个 8 位通道用于 alpha 通道,当像素被绘制到屏幕上时,它控制像素的透明度。
颜色深度很重要。我们经常使用半透明像素。因此,即使你对低位深度像素艺术感兴趣,你可能仍然想要 32 位,没有别的原因,只是为了使无缝阿尔法混合成为可能。
-
我们也用合理的默认值初始化混音器。
-
最后,我们初始化字体渲染引擎,这样我们可以稍后在屏幕上绘制文本。
现在,在这一点上,你可以用一个合适的矩形大小运行这个init
函数:
>>> init(pygame.Rect(0, 0, 1024, 768)) <Surface(1024x768x32 SW)>
如果操作正确,应该会出现一个带有黑色画布的窗口。但是你很快就会意识到,它对事件没有反应;不是从你的键盘或鼠标,也不是从你的操作系统!您的操作系统将很快报告它似乎挂起。
这是因为我们没有响应来自操作系统的事件。操作系统在这里完成所有繁重的工作:它提取数据,从你的外围设备中读取数据,并将它们传输到窗口和底层事件循环,这些循环通常会处理那些事件消息。
那么,我们如何处理这些事件呢?
游戏循环
简化到最简单的部分,一个游戏循环只不过是一个无限循环,其中有一些东西:
while True: do_something() do_something_else()
当然,事情从来没有这么简单。首先,你需要将那些你只想运行一次的事情——比如初始化 py game——和那些你肯定想多次运行的事情分开,比如在屏幕上画图,或者从鼠标或键盘上读取事件。
事实上,我们的游戏循环的存在是为了集中我们必须在每一帧做的活动。其中一些是:
-
把东西画到屏幕上
-
告诉我们所有的精灵更新他们自己:敌人不断穿越他们正在走的道路;子弹不停的飞。
-
检查是否有我们应该处理的碰撞:炮塔发现敌人;或者子弹击中敌人。
-
处理操作系统和键盘/鼠标事件
-
…以及检查我们是赢了还是输了游戏之类的事情;是否会滋生更多的敌人;等等。
最后,指示 PyGame 等待下一个周期的运行。
游戏刻度、帧数和循环
一个游戏滴答是你在上面看到的游戏循环的一次完整迭代。
如果我们的目标是每秒 60 帧,那么我们可以计算出我们在每个游戏滴答上可以花费的最多时间(或者说我们可以花费在准备单帧上的时间):
frame length = 1 second / desired frame rate
正如我解释我们的游戏循环一样,这大约是我们必须做上述所有事情的最大 16 毫秒的时间。如果你花的时间太长,你的帧速率会下降,低于预定目标;更糟糕的是,如果你的游戏循环运行时间变化很大,你将会有滞后和不稳定的控制。
然而,过高的帧速率也会使我们的游戏变得不必要的复杂。一般来说,更容易将物理(在我们的例子中,子弹行进的速度,或敌人移动的速度)等事情与游戏的帧速率联系起来。
这样,我们就可以对某事物的变化率作出一般的陈述,它是游戏节拍的倍数。例如,如果一颗子弹穿过游戏地图,覆盖 1000 个像素,移动一个像素需要多长时间?最重要的是,这是一个游戏设计的决定,而不是一个技术决定。
如果我们希望它在 2 秒内从地图的一端移动到另一端,那么它应该移动:
pixels per tick = 1000 pixels / (60 fps * 2 seconds)
大约是每刻度 8.3 个像素。
这是一种简单而有效的测量每分笔成交点变化率的方法。这种方法在这里工作得很好,因为您可以用简单的分数直观地进行推理:30 个刻度是半秒;60 是一秒;120 是 2。
因此,我们需要跟踪我们想要的帧速率。
DESIRED_FPS = 60
我将使用 Python 中常见的大写字母符号,它们是常量和,并在模块级全局定义。在演示中,您可以在constants.py
中找到它们。
通常,你会使用一个计时器来计算你的游戏循环执行了多长时间,然后等待期望帧长度和实际花费时间之间的差,以保持稳定的帧速率。
在 PyGame 中,我们不必这样做,因为 PyGame 可以为我们做到:
clock = pygame.time.Clock() while True: # ... do game loop stuff ... pygame.display.set_caption(f"FPS {round(clock.get_fps())}") clock.tick(DESIRED_FPS)
PyGame 会计算出需要等待多长时间来保持你的帧速率。帧速率会略有不同;它会向上或向下移动几帧,但毫无疑问,这主要归功于 Python 的垃圾收集器。
设计游戏循环
我谈论一个游戏循环,单个的,好像一个游戏应该只有一个。但是你可以很容易地拥有不止一个;当我们讨论有限状态自动机的时候,我们会谈到原因。多个游戏循环的主要好处是,我们可以为游戏的每个主要的、独特的部分都有一个循环:主菜单、游戏、输赢分数屏幕和地图编辑。
为什么?嗯,我们希望将我们的关注点分开,意思是彼此无关的东西:例如,主菜单和输赢分数屏幕根本不应该知道彼此。混淆他们的状态——屏幕上的敌人和炮塔,地图等。都非常不同。你可以将它们合并成一个大的、讨厌的游戏循环,但是你最终会得到大量的if
语句!
最好想一个简单的设计来避免这种混乱。在演示中,正如我在这里将要做的,我将使用类和基本的 OOP 原则来分离我们的关注点。
不用说,设计游戏循环背后的总体想法是让它足够柔韧,以服务于不同的用例,而不是用不必要的抽象给它镀太多的金。
所以我们想要的是一个类作为我们游戏的主要入口点,然后可以将控制权交给游戏循环:
-
它必须有一些初始化 PyGame 和任何其他一次性的行动。
-
它应该封装这些初始化动作的结果,就像我们通过调用
init()
得到的screen
对象一样。 -
当事件发生时,它应该知道如何将工作传递给游戏的其他部分:玩家想要编辑地图;玩家输掉或赢得游戏;等等。
这里有一个粗略的模板让我们开始。我们会继续添加内容。
from dataclasses import dataclass @dataclass class TowerGame: screen: pygame.Surface screen_rect: pygame.Rect fullscreen: bool @classmethod def create(cls, fullscreen=False): game = cls( screen=None, screen_rect=SCREENRECT, fullscreen=fullscreen, ) game.init() return game def loop(self): pass def quit(self): pygame.quit() def start_game(self): self.loop() def init(self): pygame.init() window_style = pygame.FULLSCREEN if self.fullscreen else 0 # We want 32 bits of color depth bit_depth = pygame.display.mode_ok(self.screen_rect.size, window_style, 32) screen = pygame.display.set_mode(self.screen_rect.size, window_style, bit_depth) pygame.mixer.pre_init( frequency=44100, size=32, # N.B.: 2 here means stereo, not the number of channels to # use in the mixer channels=2, buffer=512, ) pygame.font.init() self.screen = screen
我们需要引入一个新的常数SCREENRECT
,它代表画布的宽度和高度,也就是我们在初始化时从 PyGame 得到的屏幕表面:
# Replace width and height with the desired size of the game window. SCREENRECT = pygame.Rect(0, 0, width, height)
loop
方法目前还是空白。我们以后再处理。你现在知道的init
方法:它是以前的方法,但是稍微修改了一下,以便更好地利用类。
值得注意的是方法create
,一个类方法,以及@dataclass
的使用。两者都是有用的模式,所以现在让我们来讨论一下它们,因为它们会定期出现。
什么是数据类?
用 Python 编写类的一个恼人的特性是必须手动分配传递给构造函数的属性:
class SomeClass: def __init__(self, a, b, ...): self.a = a # ... etc ...
另一个是生成一个__repr__
方法,打印对象内部状态的漂亮表示,这对于 Python shell 中的调试和交互式开发非常有用。
这些只是dataclasses
模块旨在解决的众多特性中的两个。它能做的远不止这些。但这是我们目前的两个主要优势。
不是创建一个显式的构造函数,而是在应用了@dataclass
decorator 之后,使用 Python 的类型注释来表达类的需求:
@dataclass class MyClass: a: int b: str
如您所见,您可以在类本身上使用简单的类型提示来定义构造函数参数。还有一个fields
方法,用于处理那些不能用类型提示轻松捕获的事情:比如自动创建对象的实例,并在实例化对象时将它们分配给属性。
虽然我在演示中使用了类型提示,但从技术上来说,你不必这样做。你可以用typing.Any
来表示你不介意是什么类型。
我们将在本课程中大量使用数据类:它可以节省大量时间,让我们专注于比基本的家务管理更重要的事情。然而,正如您将看到的,PyGame 的一些内置类不使用 dataclasses,在这些情况下,我们将不得不恢复到在构造函数中创建和赋值的“经典”方法。
因为您用构造函数应该具有的属性来注释您的类,所以您实际上根本不需要__init__
构造函数。其实必须用__post_init__
来代替。但是正如您将在下面看到的,当您必须在对象实例化期间执行某些操作时,通常会有更聪明的方法来实现它。
因为这是一个值得单独开设一门课程的广泛话题,所以我会让你参考data class 文档以获得更多信息。
create
工厂类方法
我在 Python(或其他语言)中避免做的一件事就是复杂的__init__
构造函数。用 Python 创建构造函数太容易了,结果做了太多事情。因为构造函数总是被调用,所以当一个类被实例化时,你永远不能真正地告诉 Python 类,当你实例化这个对象时,请不要在构造函数中做一些你要做的事情——不能没有构造函数本身的特征标志,或者一些其他形式的解决方法,比如从它继承并希望你能以那种方式解决它。
以此为例:
class Foo: def init(self): # ... as before ... def __init__(self, screen, screen_rect): self.screen = screen self.screen_rect = screen_rect self.init()
这是一种非常常见的模式。人们接受一些参数——这没问题——并把它们存储在对象上(这也很好),然后他们进行一个或多个复杂的操作——改变对象内部或外部状态的“一次性”操作。在我们的例子中,一堆只能做一次的事情,比如通过调用self.init()
初始化 PyGame。
但是,如果我不希望这样呢?也许我需要两个对象,但只初始化了其中一个。也许我传入一个已经初始化的屏幕,这样我就不需要初始化两次了?上面的代码没有这个用例的启示。这个问题在测试中很突出,因为你经常想要询问和测试类的一部分。
当然,您可以创建一个开关do_not_initialize
或类似的东西,但是如果您有四个不同的可变动作呢?你有四个开关吗?谁来测试所有的组合工作?
回到create
方法。它有一个@classmethod
,所以它接受类 ( TowerGame
作为cls
参数),而不是像__init__
那样接受实例。这意味着我们可以调用TowerGame.create(...)
并让它返回一个对象,就像普通的实例化调用一样,但有一个额外的好处:
我们可以控制对象的初始化方式和参数(如果有的话)。在这里,我在实例化了TowerGame
的实例后,立即调用上的 init ,从而确保它已经设置好了。
@classmethod def create(cls, fullscreen=False): game = cls( screen=None, screen_rect=SCREENRECT, fullscreen=fullscreen, ) game.init() return game
但是如果我想的话,我也可以做TowerGame(screen=existing_screen , ...)
,我不必担心重新初始化已经初始化的东西,因为构造函数不会自动初始化。
The create
factory pattern is almost always better than a top-heavy constructor
您可以拥有任意多的这些类方法,您也不必将它们命名为create
。
当我知道对象可能以多种方式使用时,我喜欢有一个,如果我觉得手动创建类很乏味,或者如果我想设置一些“合理的默认值”,我想在实例化对象的大部分时间里设置这些默认值。这是一种捕捉你对一个对象做的所有小事情的方式,在你在一个类方法中实例化它,而不是在你代码的其他地方。
您可以在演示中查看这种模式的大量实例。
You get to have your cake and eat it, too
您的普通构造函数不受影响;如果说有什么不同的话,那就是减轻了可能做得过多的负担。在程序员使用一个对象之前,确保它的内部状态是正确的,这确实是一件合理合法的事情。
使用一个单独的构造器类方法来做这件事,并让默认的__init__
只做最基本的事情,这确保了你可以两者都做。
With dataclasses you don’t need the __init__
constructor at all
如您所见,这两种方法配合得很好:数据类为您处理构造函数的内容;类方法实现复杂对象初始化和缺省值的更复杂的部分。
运行游戏循环
当我们开始游戏时,我们需要一种运行游戏循环的方法。
def start_game(): game = TowerGame.create() game.loop() if __name__ == "__main__": start_game()
创建一个新文件——在演示中称为main.py
——并在其中放入类似的东西。当你运行python -m tower.main
时,它应该调用start_game
并调用我们的(空)循环。
后续步骤
我们需要设计实际的游戏循环,因为我们的游戏有几个不同的屏幕(一个菜单,游戏编辑器,实际的游戏,和一个比分屏幕),我们需要一种方式来考虑这个游戏的设计。这就是状态机发挥作用的地方。
摘要
Dataclasses automate a lot of tedium
它们几乎总是普通类的直接升级——老实说,我们编写的大多数类都是普通的。本课程只是触及了数据类的皮毛,所以我建议您牢记它们的用途,并在您从事的其他项目中尝试它们。
The class method factory pattern captures common requirements
很多时候,你会创建一个对象,然后马上针对那个对象进行若干活动:分配这个;称之为。如果是一次性的,没问题。但通常,这是一种通用模式,是您需要经常做的事情,或者至少表明您的类和应用程序期望有一套通用的标准。
您可以使用 classmethod 模式来帮助您管理这一点。
塔防游戏:菜单、声音和文本
原文:https://www.inspiredpython.com/course/create-tower-defense-game/tower-defense-game-menus-sound-text
Author Mickey Petersen
我们就要结束游戏了。是时候在 UI 上花些功夫了,这样我们就可以保存和加载我们制作的地图并播放它们。为此,我们需要一个小菜单系统。然后还有在游戏中加入声音的问题。
塔防游戏:入门
原文:https://www.inspiredpython.com/course/create-tower-defense-game/tower-defense-game-getting-started
Author Mickey Petersen
Python 包管理并不是最容易理解的事情。然而,重要的是我们要恰当地捕捉游戏的需求,并将它们提交到 Python 包中。
入门指南
配置 Python 环境
您需要使用 Python 3.8 或更高版本才能轻松完成本课程。演示本身明确要求 3.8 或更高版本。您可以使用旧版本,但是如果您使用旧版本,某些次要的 Python 语言特性和标准库附件将不可用。
如果您想运行演示,您必须确保您的 Python 版本安装了tkinter
,这是一个在演示中用来处理打开和保存级别的 UI 工具包。
对自己的游戏要求并不严格,但可能需要在 Ubuntu 上安装python3.X-tk
,或者在 Windows 和 Mac 上安装时确保选中 TKinter。
你可以通过输入import tkinter
来检查它是否工作。
我还建议你在虚拟环境中开发,这是一种分离你的包的方式,这样它们就不会和其他项目重叠。
现在,让我们创建一个包来托管我们的游戏。
创建 Python 包
Python 的包管理因复杂和混乱而获得了(应得的)坏名声。但是知道如何创建包是很难的事情之一,除非你做过几次。幸运的是,大多数包——甚至是您可能最终部署在某个地方的服务器上的包——通常都可以使用我下面向您展示的简单方法。
我们的目标是有一个给定包名的合适的包——演示叫做tower
,但是你应该选择一个你自己的名字——包含包需求、源代码和资产。
-
创建一个空目录。在本例中,我将其命名为
tower
。 -
在内部,如果您想要版本控制,可以选择
git init
。 -
现在,我们需要创建两个文件。第一,
setup.py
:from setuptools import setup setup()
这个占位符文件将有助于您的代码的未来。叫做
pyproject.toml
:[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta"
-
现在是最重要的文件,
setup.cfg
:[metadata] name = tower version = 1.0 description = Your description long_description = A longer description license = Your license here classifiers = Programming Language :: Python :: 3 [options] zip_safe = false packages = find: install_requires = click==8.* pygame==2.* structlog [options.package_data] tower.assets.gfx = *.png tower.assets.audio = *.wav, *.ogg tower.assets.levels = *.json [options.entry_points] # Optional, but if you want to keep it, # make sure you have a function called # main() in tower.main! console_scripts = tower = tower.main:main
这是演示中使用的示例文件(但是为了简单起见,去掉了注释并做了一些调整。)可以用自己的替换细节。
不过,重要的部分值得仔细研究:
-
install_requires
是在安装软件包时要安装的软件包的换行符列表。这当然很重要,因为我们需要这些依赖来运行我们的代码。 -
name
是安装的软件包的名称。 -
packages
自动检测您的包裹。 -
[options.package_data]
是一个位置列表,Python 应该在这些位置寻找通常不会包含的东西,比如我们的媒体资产。 -
创建一个快捷方式,您可以从您的命令提示符或终端调用。它叫做
tower
,它运行tower.main
中的main
功能。
这个声明性配置指定了包的结构。
-
-
现在是目录结构。创建一个名为
tower
的目录(或者您喜欢的任何名称。)-
在
tower
目录中,创建一个名为__init__.py
的空白文件。这是你的项目的根,这样当你输入import tower
时,一切都正常。 -
如果您想要使用与我在上面的
setup.cfg
例子中指定的相同的目录结构,您将需要更多的目录和文件:
setup.py setup.cfg tower/ ├── assets │ ├── __init__.py │ ├── audio │ │ ├── __init__.py │ ├── gfx │ │ ├── __init__.py │ └── levels │ └── __init__.py ├── __init__.py └── main.py
-
随意移动东西;需要记住的重要一点是,tower
的每个子目录都有一个__init__.py
文件。如果你不想要main.py
,那么别忘了更新console_scripts
。
现在,您已经准备好测试您的项目安装工作了。首先,我们将以可编辑模式安装它,这意味着我们告诉 setuptools(以及 Python)在您创建文件的目录中找到代码,而不是通常为您安装但不想编辑的包保留的不透明目录结构。
为此,使用setup.cfg
转到您的包的根目录并运行:
$ python -m pip install --editable .
Pip 现在将安装您的软件包。完成后,您应该会看到类似这样的内容:
[ ... lots of output ... ]
Successfully installed tower-1.0
您可以通过在一个python
shell 中键入以下内容来确认它的工作:
>>> import tower
如果您遇到错误,请检查您是否正确地复制了包结构,并且如果您重命名了内容,那么您是否一直这样做。或者,您可以使用演示中的结构开始。
如果一切正常,您将成功导入您的包,并且您已经准备好了。
Understanding package management is important
是的,我们只是在编写一个游戏,但是这种知识在一个大型的 webapp 上就像在其他任何东西上一样有效。我们的需求——事实上,除了 PyPi 上最复杂的包之外,其他包的需求——是捕获需求的一个简单例子,如果您想要包含非 python 代码,还可以捕获这些资产的位置。
Doing it properly will help you down the line
我看到很多 Python 开发者通过修改sys.path
或其他类似的诡计来颠覆打包过程。这种方法可以很好地工作,直到它不工作——比如当你想和其他人共享你的代码,或者使用从你的包中动态导入资源的能力(我们将对我们的媒体资产做同样的事情)。)
花一点时间来熟悉这个过程。也许有一天你会被要求创建一个包或者用一个包解决问题。
If you mess up, revert to a known state
对于 Git 这样的源代码控制系统来说,这是最容易做到的。您还应该确保删除了<package name>.egg-info
目录,因为 setuptools 喜欢在该目录中缓存状态。
There are third-party alternatives available also
像诗歌这样的工具有望使这个过程变得更容易或更易于管理,但是我们离摒弃和取代现有的包装机械还有很长很长的路要走。所以即使你使用诗歌,大多数其他包都不会。您最终会发现自己正在与编写包的标准方法进行交互、调试或扩展。
安装和播放塔防演示
本课程附带的源代码演示了您将要学习的所有概念。我建议您安装并使用它,只是为了对最终目标有一个感觉,并作为一个工作示例,在您自己的游戏遇到问题时可以参考。
首先,下载演示并将其解压缩到一个目录中。
接下来,您必须安装它:
$ cd <demo path>
$ python -m pip install --editable .
如果在虚拟环境中完成,您现在就可以重新编辑、运行和试验演示了。你可以通过输入tower launch
或python -m tower.main launch
来启动它。
如果操作正确,您应该会看到主菜单出现。
游戏附带一个演示关卡来演示游戏的玩法。在游戏中选择“Play”,导航到tower/assets/levels
目录,打开“demo.json”即可找到。
声音和图形资产
在tower/assets
下的演示中,你可以找到一些资产,或者你可以使用自己的资产, itch.io 是一个很好的来源。资产是包层次结构的一部分,所以我们可以使用 Python 的导入机制来导入它们,而不必担心找到它们在文件系统中的位置。
摘要
花一点时间建立一个合适的包结构并开始工作将会带来回报。
Packages capture third-party requirements
跟踪你的软件包所依赖的第三方软件包的版本可以避免版本漂移和兼容性问题。一个常见的问题是,一个较新版本的软件包发布了,而你无意中使用了一个你的软件从未测试过的软件包版本。
You can also include non-Python assets, like data files
Python 包可以包含数据文件资产,如图像、声音效果等。-如果你告诉它。稍后我们将使用它以一种可维护的方式将我们的资产加载到 pygame 中,避免弄乱显式文件路径。
用基本的 Python 解决 Wordle 难题
原文:https://www.inspiredpython.com/article/solving-wordle-puzzles-with-basic-python
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
原文:https://www.inspiredpython.com/course/game-boy-emulator/game-boy-emulator-designing-the-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 夹具模式
原文:https://www.inspiredpython.com/article/five-advanced-pytest-fixture-patterns
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,这样我就可以专注于编写实际的业务逻辑。*
注意函数参数中可变的缺省值
原文:https://www.inspiredpython.com/article/watch-out-for-mutable-defaults-in-function-arguments
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
但是永远记住不变性是一个特定对象的属性,而不一定是它所引用的任何对象的属性。可变列表可以包含不可变的元素;不可变元组可以包含可变列表。
用单个调度分离特定类型代码
原文:https://www.inspiredpython.com/article/separating-type-specific-code-with-singledispatch
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,您也可以使用它们来分派到您注册的功能。*