Python-设计模式实践教程-一-

75 阅读1小时+

Python 设计模式实践教程(一)

原文:Practical Python Design Patterns

协议:CC BY-NC-SA 4.0

一、在开始之前

设计模式帮助你从别人的成功中学习,而不是从自己的失败中学习。—马克·约翰逊

世界正在改变。

当你读这篇文章的时候,全世界的人都在学习编程,但大多很糟糕。大量的“程序员”涌向市场,你无法阻止他们。和所有的事情一样,你很快就会看到,随着程序员供给的增加,雇佣程序员的成本会降低。简单地写几行代码或改变一个软件的某些东西(就像电子表格或文字处理大师的角色)将成为一项基本技能。

如果你打算以程序员为生,这个问题会因互联网而变得更糟。你不仅要担心你所在地区的程序员,还要担心来自世界各地的程序员。

你知道谁不担心像蘑菇一样涌现的数百个代码训练营产生的数百万程序员吗?

大师们

虽然每个人都可以写作,但世界上仍然有不同的写作方式。虽然每个人都可以使用 Excel,但是仍然有财务建模者。当每个人都能编码时,世界上仍然会有彼得·诺维格斯。

编程是一种工具。有一段时间,仅仅知道如何使用这个工具就让你变得有价值。那个时代已经结束了。这是一个新的现实,也是许多行业多年来不得不面对的现实。当一个工具是新的,没有人想使用它。然后,一些人看到了价值,这使他们比那些不会使用工具的人好两倍。然后,就流行起来了。不知不觉中,每个人都可以使用互联网。突然间,能够创建一个网站变得没有价值了。所有提供咨询、收取高额服务费的企业都被边缘化了。然而,无论市场发生什么,花时间掌握工具的人都能茁壮成长。他们能够做到这一点,是因为他们能够在各个层面上超越普通人——工作质量、开发速度和最终结果的美感。

现在,我们看到了大众对简单事物的理解。下一步将是把容易的事情自动化。在生活的每个领域,简单的任务之所以简单,是因为它们不需要创造力和深刻的理解,由于这些特点,它们正是将首先交给计算机的任务。

你拿起这本书的原因是因为你想成为一名更好的程序员。你想要超越成千上万的对这个和那个的介绍。你已经准备好迈出下一步,去掌握这门手艺。

在编程的新世界中,你希望能够解决大而复杂的问题。要做到这一点,你需要能够在更高的层面上思考。就像国际象棋大师一样,他们能以比大师更大的块来消化游戏,你也需要发展以更大的块来解决问题的技能。当你能够看待一个全新的问题,并迅速将其分解成高层次的部分,即你以前已经解决过的部分,你就成为了一个指数程序员。

当我开始编程时,我还看不懂手册。封面上有一张太空入侵者的图片,它承诺教你如何自己编写一个游戏。这有点吵闹,因为游戏最终是一个for循环和一个if声明,而不是太空入侵者。但是我恋爱了。激情驱使我尽我所能学习编程的一切,这并不多。在掌握了基础知识之后,我停滞不前了,甚至在获得计算机科学学士学位时也是如此。我觉得我所学的只是基础知识的简单重复。我确实学了,但感觉很慢,令人沮丧,好像每个人都在等待什么。

在“真实世界”中,它看起来并没有太大的不同。我开始意识到,如果我想成为一名指数程序员,我必须做一些不同的事情。起初,我想过拿个硕士或博士学位,但最后我决定自己深挖。

我重读了旧的计算理论书籍,它们获得了新的意义和新的生命。我开始定期参加编码挑战,并学习更多编写代码的惯用方法。然后,我开始探索与我自己不同的编程语言和范式。不知不觉中,我对编程的思维已经完全转变了。过去很难的问题变得微不足道,一些看起来不可能的事情变得仅仅是挑战。

我还在学习,还在挖掘。

你也能做到。从你现在正在读的这本书开始。在这本书中,你会发现许多工具和心智模型可以帮助你更好地思考和编码。在你掌握了这套模式之后,积极寻找并掌握不同的工具和技术。尝试不同的框架和语言,找出它们的优点。一旦你能理解是什么让一个人喜欢上一种不同于你经常使用的语言,你可能会发现其中的一些想法可以融入你的思维方式。研究你在任何地方发现的食谱和模式,并把它们转化成你可以反复使用的抽象解决方案。有时候,你可以简单地分析一个你已经使用的解决方案:你能找到一个更优雅的方法来实现这个想法吗,或者这个想法在某些方面有缺陷吗?不断地问自己如何改进你的工具,你的技能,或者你自己。

如果你准备深入挖掘并掌握这门艺术,你会在这本书里找到指导你走向精通之路的想法。我们将在现实环境中探索一套基本的设计模式。当你这样做的时候,你将开始意识到这些设计模式是如何被看作是你将来遇到特定类型的问题时可以使用的构建模块。

我的希望是,你将使用本书中的设计模式作为蓝图;这将帮助你开始收集你自己的问题解决方案。在你读完这本书之后,你应该已经踏上了通往下一个级别的程序员之旅。您还应该了解如何将这组抽象工具翻译成您可能遇到的任何编程语言,以及如何使用其他语言中的思想和解决方案并将其纳入您的思维工具箱。

成为更好的程序员

要成为一名更好的程序员,你需要决定痴迷于精通。每一天都是一个新的机会,让你变得比前一天更好。每一行代码都是改进的机会。你是怎么做到的?

  • 当您必须在一段现有代码上工作时,让代码比您发现它时更好。
  • 尝试每天完成一个快速解决问题的挑战。
  • 寻找机会与比你更优秀的程序员一起工作(开源项目非常适合这一点)。
  • 专注于刻意练习。
  • 只要你能找到借口,就练习系统思考。
  • 收集和分析心智模型。
  • 掌握你的工具,明白它们有什么用途,不应该用来做什么。

刻意练习

安德斯·埃里克森博士研究了那些达到了我们称之为精通的表现水平的人。他发现这些人有几个共同点。第一,正如马尔科姆·格拉德威尔的书《局外人》中所流行的,这些人似乎都花了大量的时间在所讨论的技能上。实际数字各不相同,但投入的时间接近 10,000 小时。这是一段很长的时间,但是仅仅花 10,000 个小时或者 10 年来练习是不够的。有一种非常特殊的练习被证明是大师级演奏所必需的。主宰世界的技巧的关键是有意识的练习。

我们一会儿会看看这与编程有什么关系,但是让我们先看看什么是刻意练习。

刻意的练习是缓慢的、停顿的、逆流的。如果你在练习小提琴,刻意的练习需要非常缓慢地演奏这首曲子,确保你完美地弹奏每一个音符。如果你有意练习网球,这可能意味着在教练的指导下一遍又一遍地做同样的击球,做一些小的调整,直到你能够一次又一次地在那个位置完美地击球。

知识工作的问题在于,标准的刻意练习协议似乎是外来的。与编程中涉及的过程相比,一项体育任务或演奏一种乐器是相当简单的。解构设计和开发软件解决方案的过程要困难得多。很难弄清楚如何实践一种思维方式。其中一个原因是,一个已解决的问题倾向于保持解决状态。我的意思是,作为一个开发者,你很少会被要求去写一个已经写好的软件。找到反复练习的“镜头”很难。

在编程中,你想学习解决问题的不同方法。你希望找到不同的挑战,迫使你用不同的约束来解决同一类型的问题。继续努力,直到你彻底理解问题和可能的解决方案。

本质上,刻意练习有以下几个组成部分:

  • 每次练习都有一个重点。
  • 尝试和反馈之间的距离(时间)越短越好。
  • 做一些你还不能做的事情。
  • 追随前人的足迹。

单焦点

在一次特定的训练中,你只想专注于一件事的原因是,你想把你所有的注意力都放在你希望提高的元素上。你想尽一切努力把它做得完美,哪怕只有一次。然后,你想重复这个过程来得到另一个完美的回答,然后是另一个。每次练习都是对技能的一次强化。你想要强化导致完美结果的模式,而不是那些不太理想的结果。

在这本书的上下文中,我希望你一次只针对一个设计模式。真正寻求理解的不仅仅是模式是如何工作的,还有它最初为什么被使用,以及它如何应用于手头的问题。此外,考虑使用这种设计模式可能能够解决的其他问题。接下来,尝试使用您正在处理的模式来解决其中的一些问题。

你想为问题和解决问题的模式创建一个心理盒子。理想的情况是,你会发展出一种感觉,知道什么时候问题可以放进你已经掌握的盒子里,然后能够快速而容易地解决问题。

快速反馈

经常被忽视的有意识的练习之一是快速反馈循环。反馈越快,联系越强,你就越容易从中学习。这就是为什么像营销和写作这样的东西如此难以掌握。从把信放在纸上到从市场得到反馈之间的时间太长了,以至于无法真正看到实验的效果。对于编程来说,情况并非如此;您可以编写一段代码,然后运行它以获得即时反馈。这使你能够正确处理并最终达成一个可接受的解决方案。如果您更进一步,为您的代码编写可靠的测试,您将获得更多的反馈,并且您能够比每次做出更改都必须手动测试过程更快地找到解决方案。

帮助你更快地从这个过程中学习的另一个技巧是预测你想写的代码块的结果。记下你为什么期望这个特定的结果。现在编写代码,并对照预期结果检查结果。如果不匹配,尝试解释为什么会出现这种情况,以及如何用另一段代码来验证您的解释。然后,测试一下。坚持做下去,直到你掌握为止。

你会发现自己得到了不同层次的反馈,每一个层次都有自己的优点。

第一个层次就是解决方案是否有效。接下来,您可能会开始考虑诸如“该解决方案实施起来有多容易?”或者“这个解决方案适合这个问题吗?”稍后,您可能会寻求外部反馈,其形式可以是简单的代码审查、项目工作或与志同道合的人讨论解决方案。

伸展你自己

你回避的事情是什么?这些是编程中让你感到不舒服的地方。它可能是从磁盘上的文件中读取或者连接到远程 API。无论是图形库还是机器学习系统都没有区别,我们都有不舒服的地方。这些通常是你最需要努力的事情。这里有一些领域会拓展你,迫使你面对自己的弱点和盲点。克服这种不适的唯一方法是深入其中,以多种方式多次使用这个工具,这样你就能开始对它有感觉了。您必须对它非常熟悉,这样您就不必再在堆栈溢出时查找文件打开协议;事实上,你写了一个更好的。你用 GUI 跳绳,一只手绑在背后从数据库中吸取数据。

达到这种精通水平没有捷径;唯一的路是穿过这座山。这就是为什么很少有人成为真正的大师。到达那里意味着花很多时间在不容易的事情上,这不会让你觉得你是不可战胜的。你花了太多时间在这些自我毁灭的地带,以至于很少有任何一门手艺的大师还留有许多傲慢。

那么,你应该先做什么呢?你读前面两段时想到的那件事。

按照本书中的设计模式工作是发现潜在增长领域的另一个好方法。只需从单例模式开始,然后一步步来。

站在巨人的肩膀上

有些人在编程领域做出了惊人的成就。这些人经常在开发者大会上发表演讲,有时也会出现在网上。看看这些人在说什么。试着理解他们是如何处理一个新问题的。在他们演示解决方案时,和他们一起打字。进入他们的大脑,找出是什么让他们跳动。试着解决一个问题,就像你想象他们中的一个会做的那样;你用这种方式想出的解决方案和你自己想出的有什么不同?

真正伟大的开发人员对编程充满热情,不需要太多的刺激就能让他们谈论技术的细节。找出这类开发人员经常出没的用户群,并展开大量对话。保持开放,不断学习。

选择那些强迫你使用你尚未掌握的设计模式的个人项目。好好享受吧。最重要的是,学会热爱这个过程,不要沉迷于一些可感知的结果,而不是花时间成为一名更好的程序员。

你是怎么做到的?

就像列奥纳多·达·芬奇决定以绘画为职业时那样开始。

收到。

没错。首先确定一些有趣的问题,一个已经解决的问题,然后公然复制解决方案。不要复制/粘贴。自己打出来复制解答。让您的副本发挥作用。一旦你做了,全部删除。现在试着从记忆中解决问题,当你的记忆让你失望时,只参考最初的解决方案。这样做,直到您能够在不看原始解决方案的情况下,完美地重现解决方案。如果你在寻找可以复制和借鉴的问题解决方案,Github 就是一座金矿。

一旦你对你找到的解决方案感到满意,试着改进最初的解决方案。你需要学会思考你在那里找到的解决方案——是什么让它们变得好?如何让它们更优雅、更地道、更简单?总是寻找机会以这些方式中的一种使代码变得更好。

接下来,你想把你的新解决方案带到野外。找到使用解决方案的地方。在现实世界中练习迫使你处理不同的约束,那种你自己永远不会想到的约束。它会迫使你以从未想过的方式改变你漂亮、干净的解决方案。有时候,你的代码会崩溃,你会学会欣赏最初解决问题的方式。其他时候,你可能会发现你的解决方案比原来的更好。问问你自己,为什么一个解决方案优于另一个,这个问题和解决方案教会了你什么。

尝试使用不同范式的语言来解决类似的问题,从每种语言中吸取经验,然后自己形成解决方案。如果你像实际解决问题一样关注过程,没有项目会让你置身事外。

从事开源项目的美妙之处在于,通常会有一些人会帮助你,还有一些人会告诉你你的代码到底出了什么问题。评估他们的反馈,学习你所能学到的,抛弃其他的。

航向修正的能力

当探索一个问题时,你有两个选择:继续这样尝试,或者放弃一切,用我学到的重新开始。丹尼尔·卡纳曼在他的《思考,快与慢》一书中解释了沉没成本谬误。这是你继续投资于一个糟糕的投资的地方,因为你已经投资了这么多。这对于一个开发者来说是一个致命的陷阱。让一个两天的项目花上几个月的最快方法是尝试通过一个糟糕的解决方案。如果我们放弃一天、一周或一个月的工作,从零开始,这通常会是一个巨大的损失。

事实是,我们从来没有从零开始,有时您删除的最后 10,000 行代码正是您需要编写的 10,000 行代码,您需要成为一名程序员,用 100 行代码用一个惊人的优雅解决方案解决问题。

你需要培养意志,说够了就够了,然后重新开始,用你所学到的建立一个新的解决方案。

如果一个解决方案是错误的,那么在这个解决方案上花费的时间再多也没有任何意义。你越早意识到这一点越好。

这种自我意识让你有能力知道什么时候再尝试一件事,什么时候朝不同的方向前进。

系统思维

一切都是一个系统。通过理解什么元素组成了系统,它们是如何连接的,以及它们如何相互作用,你就能理解这个系统。通过学习解构和理解系统,你不可避免地教会了自己如何设计和构建系统。每一个软件都表达了这三个核心组件:组成解决方案的元素,以及它们之间的一组连接和交互。

在一个非常基础的层面上,你可以从问你自己开始:“我想要建立的系统的元素是什么?”把这些写下来。然后,写下它们是如何相互联系的。最后,列出这些元素之间的所有相互作用以及起作用的联系。这样做,你就完成了系统的设计。

所有设计模式都处理这三个基本问题:1)元素是什么,它们是如何创建的?2)元素之间有什么联系,或者说结构是什么样子的?3)元素如何交互,或者它们的行为看起来像什么?

还有其他方法来分类设计模式,但是现在使用经典的三个分组来帮助你发展你的系统思维技能。

心理模型

心理模型是外部世界的内在表现。你对世界的模型越精确,你的思维就越有效。地图不是领域,所以拥有更准确的心智模型会让你对世界的看法更准确,你的心智模型越多才多艺,你就能解决越多种多样的问题。在本书中,你将学到一套思维工具,帮助你解决在你的程序员生涯中经常遇到的特定编程问题。

看待心智模型的另一种方式是将它们视为一组概念,组成一个单一的思维单元。设计模式的研究将帮助你开发新的思维模式。一个问题的结构将暗示你需要实现什么样的解决方案来解决这个问题。这就是为什么你要完全清楚你要解决的问题是什么是很重要的。问题定义或描述越好——我的意思是越完整——你对可能的解决方案的暗示就越多。所以,就这个问题问自己一些愚蠢的问题,直到你有了一个清晰简单的问题陈述。

设计模式帮助你从 A(问题陈述)到 C(解决方案),而不必经过 B 和许多其他错误的开始。

适合这项工作的工具

要打破砖墙,你需要一把锤子,但不是随便一把——你需要一把又大又重的长柄锤子。当然,你可以用你用来在音乐盒里钉钉子的那把锤子砸开这面墙,但是用合适的工具敲一个下午的时间,却要花上你好几辈子的时间。

使用错误的工具,任何工作都会变得一团糟,花费的时间也会比预期的长。为这项工作选择合适的工具是一个经验问题。如果你不知道除了你习惯的小锤子还有锤子,你很难想象有人能在几个小时内推倒整面墙。你甚至可以称这样的人为 10 倍破壁人。我想说的是,有了合适的工具,你将能够比那些试图用现有资源凑合的人多做许多倍的工作。花时间和精力扩展你的工具箱,掌握不同的工具是值得的,这样当你遇到一个新问题时,你就知道选择哪一个。

要成为一名编程大师,你需要不断地向你的武器库中添加新的工具,不仅仅是熟悉它们,而是掌握它们。我们已经看了掌握您决定的工具的过程,但是在 Python 的环境中,让我提出一些具体的建议。

Python 生态系统的一些美妙之处是可以直接获得的包。有很多包,但通常你可能遇到的每种类型的问题都有一个或两个明确的领导者。这些包是有价值的工具,您应该每周花几个小时来研究它们。一旦你掌握了本书中的模式,抓住 Numpy 或 Scipy 并掌握它们。然后,向你想象的任何方向前进。下载您感兴趣的包,学习基础知识,然后开始使用已经提到的框架进行试验。它们闪耀在哪里,又缺少了什么?他们特别擅长解决什么样的问题?你将来如何使用它们?你能做什么样的辅助项目来让你在现实世界的场景中尝试这个包?

作为概念的设计模式

四人帮关于设计模式的书似乎是一切开始的地方。他们提出了一个描述设计模式的框架(特别是针对 C++ ),但是描述通常集中在解决方案上,结果许多模式被翻译成多种语言。该书中列出的 23 种设计模式的目标是为面向对象编程中遇到的常见问题编写最佳实践解决方案。因此,解决方案关注于类及其方法和属性。

这些设计模式每一个都代表一个完整的解决方案,它们将变化的东西与不变的东西分开。

有许多人对最初的设计模式持批评态度。其中一位批评家 Peter Norvig 展示了如何用 Lisp 中的语言结构来代替其中的 16 种模式。这些替换在 Python 中也是可能的。

在这本书里,我们将会看到几个原始的设计模式,以及它们如何适应现实世界的项目。我们还将在 Python 语言的上下文中考虑关于它们的争论,有时为了使用该语言的标准解决方案而放弃该模式,有时改变 GoF 解决方案以利用 Python 的强大功能和表达能力,而其他时候只是以 Python 的方式实现原始解决方案。

什么构成了设计模式?

设计模式可以是很多东西,但它们都包含以下元素(鸣谢:彼得·诺维格, http://norvig.com/design-patterns/ppframe.htm ):

  • 模式名称
  • 意图/目的
  • 别名
  • 动机/背景
  • 问题
  • 解决办法
  • 结构
  • 参与者
  • 协作
  • 后果/限制
  • 履行
  • 示例代码
  • 已知用途
  • 相关模式

在附录 A 中,您可以找到我们在本书中讨论的所有设计模式都是根据这些元素构建的。出于可读性和学习过程的考虑,关于设计模式本身的章节不会都遵循这种结构。

分类

设计模式被分为不同的组,以帮助我们作为程序员彼此谈论解决方案的类别,并在讨论这些解决方案时给我们一个共同的语言。这使我们能够清晰地交流,并在讨论中表达我们的主题。

正如本章前面提到的,我们将根据创造、结构和行为模式的原始分组来对设计模式进行分类。这样做不仅是为了坚持一般的做事方式,也是为了帮助你,读者,在他们所处的系统环境中查看模式。

创造型的

第一类处理系统中的元素——特别是它们是如何被创建的。当我们处理面向对象编程时,对象的创建是通过类实例化来实现的。正如您将很快看到的,在解决特定问题时,有不同的特性是可取的,而创建对象的方式对这些特性有重大影响。

结构的

结构模式处理类和对象是如何组成的。可以使用继承来组合对象,以获得新的功能。

行为的

这些设计模式专注于对象之间的交互。

我们将使用的工具

世界正在向 Python 3 转变。这种转变是缓慢而深思熟虑的,就像冰川一样,无法停止。为了这本书,我们将发布 Python 2,拥抱未来。也就是说,您应该能够毫不费力地使用 Python 2 编写大部分代码(这并不是因为书中的代码,而是 Python 核心开发人员出色工作的结果)。

对于 Python,特别是 CPython(默认的),您可以使用 Pip,Python 包安装程序。Pip 与 PyPI(Python 包索引)集成,允许您从包索引下载和安装包,而无需手动下载包、解压缩包、运行 python setup.py install 等。Pip 让为您的环境安装库成为一种乐趣。Pip 还为您处理所有的依赖项,因此没有必要在您尚未安装所需的软件包后到处跑。

当你开始着手第三个项目时,你会需要很多包。并非所有的项目都需要这些包。您将希望保持每个项目的包都被很好地包含。进入 VirtualEnv,这是一个虚拟的 Python 解释器,它将为该解释器安装的包与系统上的其他包隔离开来。您可以将每个项目放在自己的极简空间中,只安装它工作所需的包,而不会干扰您可能正在进行的其他项目。

如何阅读这本书

阅读一本书的方法有很多,尤其是编程书。大多数人在开始读这本书的时候都希望能从头到尾读一遍,结果只是从一个代码示例跳到另一个代码示例。我们都做过。记住这一点,你可以通过多种方式阅读这本书,并从花在这本书上的时间中获得最佳价值。

第一批读者只想快速、可靠地参考 GoF 设计模式的 pythonic 版本。如果这是你,请跳到附录 A,查看每个设计模式的正式定义。当你有时间的时候,你可以回到相关的章节,继续探索你感兴趣的设计模式。

第二类人希望能够找到具体问题的具体解决方案。对于那些读者来说,每一章的设计模式都是从描述模式所解决的问题开始的。这将有助于您决定该模式是否有助于解决您所面临的问题。

最后一组想用这本书来掌握自己的手艺。对于这些读者,我建议你从头开始,通过你的方式编写这本书。把每个例子都打出来。修补每一个解决方案。做所有的练习。看看修改代码后会发生什么。什么东西坏了,为什么?让解决方案更好。一次处理一个模式,并掌握它。然后,找到其他你可以应用新知识的真实环境。

设置 Python 环境

让我们从让 Python 3 环境在您的机器上运行开始。在这一节中,我们将研究在 Linux、Mac 和 Windows 上安装 Python 3。

在 Linux 上

我们使用的命令是针对 Ubuntu 的 apt 包管理器。对于其他不支持 apt 的发行版,可以看看使用另一个包管理器(比如 yum)安装 Python 3 和pip的过程,或者从源代码安装相关的包。

整个安装过程都将使用终端,所以您现在可以开始打开它了。

让我们首先检查您的系统上是否已经安装了 Python 的一个版本。

只需注意:在本书的持续时间内,我将用前导$来表示终端命令;在终端中输入注释时,不要键入该字符或后续空格。

$ python --version

如果您已经安装了 Python 3(Ubuntu 16.04 和更高版本就是这种情况),您可以直接跳到安装 pip 一节。

如果您的系统上安装了 Python 2 或者没有安装 Python,您可以使用以下命令安装 Python 3:

$ sudo apt-get install python3-dev

这将安装 Python 3。您可以像以前一样检查 Python 安装的版本,以验证是否安装了正确的版本:

$ python3 --version

Python 3 现在应该可以在您的系统上使用了。

接下来,我们安装 build essentials 和 Python pip:

$ sudo apt-get install python-pip build-essential

现在,检查pip是否正常工作:

$ pip --version

您应该看到您的系统上安装了一个版本的pip,现在您已经准备好安装virtualenv包了;请跳过 Mac 和 Windows 安装说明。

在 Mac 上

macOS 默认安装了 Python 2 版本,但是我们不需要它。

为了安装 Python 3,我们将使用 Homebrew,这是一个用于 macOS 的命令行包管理器。要做到这一点,我们需要 Xcode,你可以从 Mac AppStore 免费获得。

安装 Xcode 后,打开“终端”应用并安装 Xcode 的命令行工具:

$ xcode-select --install

只需按照弹出窗口中的提示安装 Xcode 的命令行工具。完成后,您就可以安装自制软件了:

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

如果您还没有安装 XQuartz,您的 macOS 可能会遇到一些错误。如果出现这种情况,可以在这里下载 XQuartz.dmg:https://www.xquartz.org/。然后,检查您是否成功安装了 Homebrew 并且它正在工作:

$ brew doctor

要从终端中的任何文件夹运行brew命令,需要将 Homebrew 路径添加到您的PATH环境变量中。打开或创建~/.bash_profile,并在文件末尾添加以下行:

export PATH=/usr/local/bin:$PATH

关闭并重新打开终端。重新打开后,新的PATH变量将包含在环境中,现在您可以从任何地方调用brew。使用brew查找 Python 可用的包:

brew search python

您现在将看到所有与 Python 相关的包,python3是其中之一。现在,使用下面的brew命令安装 Python 3:

$ brew install python3

最后,您可以检查 Python 3 是否已安装并正在运行:

python3 --version

当你用 Homebrew 安装 Python 的时候,你也安装了相应的包管理器(pip)、Setuptools 和 pyvenv(一个virtualenv的替代品,但是对于这本书你只需要pip)。

检查 pip 是否正常工作:

$ pip --version

如果看到版本信息,说明pip已经成功安装在您的系统上,您可以跳过 Windows 安装部分,跳到使用pip安装 VirtualEnv 的部分。

在窗口上

首先下载 Python 3 Windows 安装程序。你可以在这里下载安装程序: https://www.python.org/

下载完成后,运行安装程序并选择自定义选项。确保选择安装 pip。此外,选择将 Python 添加到环境变量的选项。

随着安装,你也变得空闲,这是一个交互式 Python shell。这使您可以在实时解释器中运行 Python 命令,这对于测试想法或试验新的包来说是非常好的。

安装完成后,打开命令行界面:

windowsbutton-r
cmd

这将打开一个类似于 Mac 和 Linux 上可用的终端。

现在,检查 Python 是否在工作:

$ python --version

如果您看到版本信息,Python 已安装并链接到该路径,这是应该的。

在我们继续之前,检查一下pip是否在工作:

$ pip --version

VirtualEnv(虚拟环境)

在开始安装 VirtualEnv 之前,让我们确保已经安装了最新版本的pip:

$ pip install --upgrade pip

Windows 用户将看到如下命令提示符:

c:\some\directory>

在 Mac 上你有

#

在 Linux 上是这样的

$

在本书中,我将使用$来表示终端命令。

使用pip安装virtualenv包:

$ pip install virtualenv

现在您已经有了virtualenv包,让我们用它来创建一个使用这本书的环境。

在您的终端中,转到您将工作的目录。接下来,创建一个虚拟环境,您将在其中安装本书中的项目所需的包。我们的虚拟环境将被称为ppdpenv;``env只是为了让我们知道它是一个虚拟环境。每当我们激活这个环境并使用pip命令安装一个新的包时,这个新的包将只安装在这个环境中,并且只在这个环境激活时对程序可用。

$ virtualenv -p python3 ppdpenv

在 Windows 上,您需要一个稍微不同的命令:

$ virtualenv -p python ppdpenv

如果 Python 3 不是您的PATH的一部分,您可以使用您想要在您的virtualenv中使用的 Python 可执行文件的完整路径。

安装过程结束后,您将在运行virtualenv命令时所在的目录中拥有一个名为ppdpenv的新目录。

要激活虚拟环境,请在 Mac 和 Linux 上运行以下命令:

$ source ./ppdpenv/bin/activate

在 Windows 上:

$ source ppdpenv\Scripts\activate

最后,检查 Python 的版本,确保安装了正确的版本。

就这样——您已经准备好用 Python 编程了!

要退出虚拟环境,您可以运行以下命令:

$ deactivate

编辑

任何可以保存纯文本文件的文本编辑器都可以用来编写 Python 程序。除了记事本,你可以什么都不用写下一个优步,但这会很痛苦。每当你写一个任意长度的程序时,你需要一个编辑器来突出你的代码,用不同的颜色标记不同的关键字和表达式,这样你就可以很容易的区分它们。

以下是我最喜欢的文本编辑器的简短列表,每个都有一些注释。

原子

Github 开发了一个编辑器,他们称之为 Atom,它是一个简单、漂亮、功能强大的编辑器。它提供了简单的 git 集成和良好的包系统(包括一个实时的 markdown 编译器)。事实上,它是基于电子的,这意味着无论你想在什么平台上工作,你都可以随身携带你的编辑器。如果它能运行 Chromium,它就会运行 Atom。

拿到这里: https://atom.io/

看版台

这款基于 ClojureScript(一种编译成 JavaScript 的 Lisp 方言),有 Vim 和 Emacs 两种模式,所以你可以把自己从鼠标中解放出来。像大多数现代代码编辑器一样,LightTable 也提供了直接的 git 集成。它是开源的,易于定制。

拿到这里: http://lighttable.com/

PyCharm

Jetbrains 为 Python 编辑器制定了行业标准。它提供了很棒的代码补全、Python linter、未使用的导入警告等等,但对我来说,它的致命特性是代码拼写检查,它同时考虑了 Camel 和 Snake 两种情况。

PyCharm 的企业版并不便宜,但他们提供免费的学生和社区许可证。

拿到这里: https://www.jetbrains.com/pycharm/download

精力

大多数基于 UNIX 的操作系统都已经安装了 Vim。Vim 有时被称为你无法摆脱的编辑器(提示:“:q!”让你摆脱)。Vim 最强大的地方在于它的快捷键可以做任何事情,从选择代码块到跳转到代码中的特定行——任何事情都不需要离开键盘。多年来,Vim 积累了大量的扩展、颜色主题和一个成熟的 IDE 梦寐以求的所有特性。它有自己的包管理器,让你完全控制代码编辑的每一个方面。它是免费的,是我见过的所有编辑器中最快的。尽管 Vim 有很多优点,但它很难学——非常难。每当我想写一个快速脚本或者做一些小的改变,并且不想等待其他编辑器/IDE 启动时,我都会使用 Vim。

通过这里设置 Python 的 Vim:https://realpython.com/blog/python/vim-and-python-a-match-made-in-heaven/

编辑器

令人惊叹的操作系统;如果它有一个好的文本编辑器就好了。Emacs 是建立在 Emacs Lisp 之上的,并且是可定制的。你可以用它做任何事情,从发送电子邮件到启动咖啡机,只需一个简单的快捷键组合。像 Vim 一样,它是无鼠标的,但是它有一个更陡峭的学习曲线。那些了解内情的人对此深信不疑,而且理由充分。您拥有现代 IDE 提供的所有代码完成和分屏选项,并且能够在需要时调整系统。您不必等待某个供应商创建一个包来处理一些新语言特性;你可以自己轻松搞定。Easily 用得很不严谨。有了 Emacs,你可以让你的编辑按照你认为应该做的方式去做事情,而编程的很大一部分是因为对目前做事情的方式不满意,而采取不同的方式。

通过阅读以下内容为 Python 开发设置 Emacs:https://realpython.com/blog/python/emacs-the-best-python-editor/

崇高的文本

这与其说是一个真正的建议,不如说是一个荣誉的提及。Sublime 曾经是最好的免费选项(每隔几个救球就有 nag 屏幕)。它有一个漂亮的默认配色方案,启动相当快,并且是可扩展的。它是 Python 就绪的,但它的时代可能已经过去了。

如果你有兴趣看的话,可以在这里得到: https://www.sublimetext.com/3

摘要

最后,您选择使用哪个编辑器并不重要。必须是你觉得舒服的事情。无论你选择什么,花时间掌握这个工具。如果你想富有成效,你必须掌握你每天必须使用的任何工具或流程,不管它看起来有多平凡。我的建议是你选择其中的一个来彻底学习,如果它不是 Vim 或 Emacs,至少学习足够的 Vim 来编辑其中的文本。这会让你花在服务器上编辑软件的时间变得更加愉快。

现在,您已经设置好了环境,并将其与系统的其余部分隔离开来。您也已经安装了您选择的编辑器并准备好了,所以让我们不要浪费任何时间。让我们开始看看一些实用的 Python 设计模式。

二、单例模式

只能有一个!–——康纳·麦克劳德

问题

您学习的第一个调试技术之一是简单地将某些变量的值打印到控制台。这有助于您了解程序内部的情况。当你写的程序变得越来越复杂时,你就不能有效地在脑子里运行整个程序了。这是当你需要在特定时间打印出某些变量的值时,特别是当程序没有像你期望的那样运行时。在程序执行的不同阶段查看程序的输出有助于快速定位和修复错误。

你的代码很快就被print语句弄得乱七八糟,这没什么,直到有一天你部署了你的代码。在服务器上运行您的代码,作为您自己机器上的调度作业,或者作为客户端计算机上的独立软件,这意味着当出现问题时,您再也不能依赖控制台提供反馈。您无法知道哪里出了问题,也无法知道如何重现问题。这将调试从科学领域直接带入了赌博。

最简单的解决方案是用一个命令代替print语句,将输出写到一个文件而不是控制台,如下所示:

with open("filename.log", "a") as log_file:
  log_file.write("Log message goes here.\n")

现在你可以看到当程序出错时发生了什么。你的日志文件就像飞机上的黑匣子:它记录着你的程序的执行情况。当你的程序崩溃时,你可以打开黑盒,看看导致崩溃的原因,以及你应该从哪里开始寻找错误。一行比两行好,所以创建一个函数来处理文件的打开和写入:

def log_message(msg):
  with open("filename.log", "a") as log_file:
    log_file.write("{0}\n".format(msg))

如果您想记录程序的某些状态以供以后查看,您可以使用这个替换语句来代替print语句:

log_message("save this for later")

log_message函数打开一个文件,并将传递给它的消息附加到文件中。这就是干原理在起作用。干?你问。它代表不要重复自己。在最基本的层面上,每当您想要复制和粘贴代码时,您应该会听到头脑中发出一点警报。只要有可能,您应该以这样一种方式重新打包代码,即您可以重用它,而不需要将代码行复制并粘贴到其他地方。

为什么你要重新思考有效的代码呢?为什么不临摹一下,只改一两件呢?如果你的代码在一个地方,你必须改变一些东西,你只需要在那个地方改变它。例如,如果您将日志记录器的代码分散在整个程序中,并且您决定更改将日志写入的文件的名称,那么您必须在许多地方更改该名称。你会发现这是一个灾难的配方;如果您丢失了一行输出代码,您的一些日志消息将会丢失。在编程中,导致错误的首要原因是人。如果您需要处理一个跨越数千甚至数百万行代码的项目,并且您需要更改日志文件名之类的内容,那么在更新过程中,您很容易漏掉这里或那里的一行。只要有可能,就要消除代码中的人机交互。

优秀的程序员写出扎实的代码;伟大的程序员编写的代码能让一般的程序员写出可靠的代码。

使代码更加用户友好的一种方法是在项目中包含一个只包含日志功能的文件。这允许您将日志功能导入到项目中的任何文件中。对项目中日志记录方式的所有更改只需在这一个文件中进行。

让我们创建一个名为logger.py的新文件,并在文件中编写以下代码:

logger.py

def log_message(msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("{0}\n".format(msg))

现在,我们可以使用新的 logger 函数将日志消息写到文件系统的main_script.py文件中:

main_script.py

import logger

for i in range(4):
  logger.log_message("log message {}".format(i))

文件的第一行- import logger告诉 Python 导入logger.py文件(注意在导入文件时不要添加.py)。导入文件允许您在main_script.py文件中使用logger.py文件中定义的功能。在前面的代码片段中,我们有一个循环,我们告诉 Python 在每次循环运行时写一条消息log message i,其中i是循环在范围中的索引。

打开filename.log文件,验证消息确实如预期的那样被写入。

随着日志消息数量的增加,您会发现想要区分它们。最有用的区别是用于描述导致所述消息的事件严重性的术语。

为了我们的记录器,我们将处理以下级别:

  • 批评的
  • 错误
  • 警告
  • 信息
  • 调试

幸运的是,我们只有一个文件需要更新(我们把这个日志程序移到了它自己的文件中,你不高兴吗?).

在日志文件中,我们希望每条消息前面都加上与该消息相关联的级别。这有助于我们轻松地扫描文件中特定类型的消息。您可以在某些命令行工具中使用这种约定,比如在*中使用grep。nix 环境中只显示特定级别的消息。

升级我们的日志功能后,看起来是这样的:

logger.py

def critical(msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[CRITICAL] {0}\n".format(msg))

def error(msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[ERROR] {0}\n".format(msg))

def warn(msg):

  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[WARN] {0}\n".format(msg))

def info(msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[INFO] {0}\n".format(msg))

def debug(msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[DEBUG] {0}\n".format(msg))

您不需要更改导入代码的方式;您只需使用想要保存的消息级别,如下所示:

test_error_log.py

import logger

try:
  a = 1 / 0

except:
  logger.error("something went wrong")

如果您查看_test_error_log.py__函数的输出,您会看到消息现在将级别作为前缀添加到了行中:

[ERROR] something went wrong

我们的日志项目现在有了一些非常有用的功能,但是我敢打赌,logger.py文件的编写方式会让您感到困扰。你脑子里的警铃刚刚响了,不是吗?

如果是的话,你是完全正确的!

我们不应该复制相同的指令来打开文件和将消息附加到文件,只是每个函数的前缀字符串不同。让我们重构我们的代码,这样我们就不会重复。

重复的部分是我们想要从所有复制代码的方法中提取的部分。下一步是对函数进行足够的一般化,以便每个函数都可以使用它,而不会失去它的任何原始功能。

在每个重复的函数中,前缀是函数与函数之间唯一不同的地方。因此,如果我们要编写一个将消息和级别作为参数的函数,我们可以在每个其他函数中使用该函数,并在每种情况下将其缩减为一行代码。

我们现在有了一个更短、更清晰的记录器,可以在其他项目中使用。

logger.py

def write_log(level, msg):
  with open("/var/log/filename.log", "a") as log_file:
    log_file.write("[{0}] {1}\n".format(level, msg))

def critical(msg):
  write_log("CRITICAL",msg)

def error(msg):
  write_log("ERROR", msg)

def warn(msg):
  write_log("WARN", msg)

def info(msg):
  write_log("INFO", msg)

def debug(msg):
  write_log("DEBUG", msg)

这个看起来更好。它简单明了,因为每个函数只做一件事。write_log函数只是将消息级别和消息文本写入文件。每个日志编写器简单地调用write_log函数,将消息的级别添加到调用中。

我相信你真的开始喜欢我们的小日志了,但是现在最困扰我的是我们用来保存所有日志文件的硬编码。您现在知道了日志记录使您成为一名更好的开发人员,并为您节省了大量时间。所以,这是一个非常好的记录器,你肯定想在你自己的项目中使用它。您希望日志记录器做的最后一件事是将来自不同项目的消息写入同一个文件。

为了避免这个问题,我们可以将文件名作为参数添加到我们在记录消息时调用的函数中。

继续实施这一改变。请注意,下面的代码片段仍然不完整,因为write_log函数仍然带有一个filename参数。

logger.py

def write_log(filename, level, msg):
  with open(filename, "a") as log_file:
    log_file.write("[{0}] {1}\n".format(level, msg))

  def critical(msg):
    write_log("CRITICAL",msg)

  def error(msg):
    write_log("ERROR", msg)

  def warn(msg):
    write_log("WARN", msg)

  def info(msg):
    write_log("INFO", msg)

  def debug(msg):
    write_log("DEBUG", msg)

filename参数不会随着调用的不同而改变,因此反复传递相同的值不是正确的做法。我们从其他函数中提取出write_log函数的真正原因是我们不必重复相同的代码。我们希望设置一次日志记录器,使用它应该记录的日志文件,然后使用它,而不用再注意选择要写入的文件。

输入对象

Python 中的类允许您定义数据和函数的逻辑分组。它们还允许您向记录器添加一些上下文数据(比如您想要写入哪个文件)。

为了充分利用课堂,你需要用一种稍微新的方式思考。

这种将彼此相关的函数和数据分组到一个类中的方式形成了一个蓝图,用于创建数据的特定实例(版本)以及它们相关的函数。一个类的实例称为对象。

为了帮助您理解这一点,请考虑一下我们刚刚开发的记录器。如果我们能够以这样一种方式来一般化这个记录器,即当我们使用它时,我们可以向它发送要使用的文件的名称,我们将有一个蓝图来创建任何记录器(一个类)。当我们进行这个调用时,我们有效地创建了一个新的记录器,它写入一个特定的文件。这个新的记录器称为类的实例。

将数据和改变数据的函数看作一个单一的实体是面向对象编程的基础。

我们现在将实现一个简单的Logger类作为例子。

logger_class.py

class Logger(object):
  """A file-based message logger with the following 
properties

  Attributes:

    file_name: a string representing the full path of the log file to which this logger will write its messages

  """

  def __init__(self, file_name):
    """Return a Logger object whose file_name is *file_name*"""

    self.file_name = file_name

  def _write_log(self, level, msg):
    """Writes a message to the file_name for a specific Logger instance"""

    with open(self.file_name, "a") as log_file:
      log_file.write("[{0}] {1}\n".format(level, msg))

  def critical(self, level, msg):
    self._write_log("CRITICAL",msg)

  def error(self, level, msg):
    self._write_log("ERROR", msg)

  def warn(self, level, msg):
    self._write_log("WARN", msg)

  def info(self, level, msg):
    self._write_log("INFO", msg)

  def debug(self, level, msg):
    self._write_log("DEBUG", msg)

这里发生了很多事情,所以在我向您展示如何在另一个项目中实现这个新的日志记录器之前,先看一下代码。

与我们之前的日志记录器的第一个大的不同是添加了class关键字,它的作用类似于def关键字,因为它告诉 Python 我们现在要定义一个新的类。然后,我们有了类名,当我们想要创建这个类的新对象时,我们将使用这个类名(正如您可能已经猜到的,称为实例)。

面向对象编程(OOP)的主要好处之一是,一旦定义了一个类,就可以重用该类来创建其他类。这个过程称为继承,因为子类从父类(用于创建子类的类)继承特征(数据和功能蓝图)。在 Python 中,每个类最终都继承自object类。在我们的记录器的例子中,我们没有使用其他类作为基础,所以这里我们只说Logger只有object作为它的父类。

接下来,我们有一些用"""字符包装的文本,表示包含的文本是一个文档字符串。某些集成开发环境(ide)和其他程序知道寻找这些字符串,并使用它们来帮助指导想要使用该类的程序员。

类并不是唯一可以利用文档字符串的结构,您还会看到函数也可以有自己的文档字符串,就像__init__函数一样。

每当实例化一个新的Logger对象时,就会调用__ init __。在这种情况下,它将文件名作为参数,并将其与正在创建的实例相关联。当我们想要引用这个文件名时,我们必须告诉对象中的方法在它自己的属性列表中寻找file_nameself关键字用于让代码知道我们引用了与发出调用的对象相关联的属性或方法,因此,它不应该在其他地方寻找这些元素。

关于属性和方法这两个术语,我只做一个简单的说明。属性是与类蓝图中定义的对象相关联的数据。对这些数据执行操作的函数称为方法。当我们将self关键字与一个方法一起使用时,就像这样:

self.some_method()

实际发生的是 Python 解释器调用方法some_method并将对象本身作为变量传递给函数,这就是为什么在对象的方法中有self作为参数,但在进行调用时没有显式地传递它。

另一件要注意的事情是,当引用对象中的属性时,如

self.some_attribute

Python 所做的是调用一个名为__getattr__的方法,并传入对象本身以及它所请求的属性名。您将很快看到一个这样的例子。

__init__方法中,您将看到我们设置了一个名为file_name的属性。这允许我们在需要使用_write_log方法时请求我们第一次创建类时设置的file_name属性的值。

除了我们现在还必须为我们讨论过的参数self做准备之外,其余的都与我们之前所做的很接近。

作为一个良好的实践,__init__方法的结果必须是一个完全初始化的对象。这意味着对象必须可以使用;在它可以执行其功能之前,不应该需要调整一些其他设置或执行一些方法。

如果你对_write_log中的前导下划线感到疑惑,这只是告诉其他程序员这个方法不应该被任何外部程序使用的一个约定;据说是私人的。

现在,我们可以看看这个新的Logger类将如何被使用。

new_script_with_logger.py

from logger_class import Logger

logger_object = Logger("/var/log/class_logger.log")

logger_object.info("This is an info message")

我们现在可以从 Python 文件中导入一个特定的类,方法是告诉解释器我们想要使用哪个文件,然后告诉它应该导入什么类。从不同的包中导入可能会比这更复杂,但是现在只要意识到可以导入想要使用的特定类就足够了。包是一组 Python 文件,它们位于计算机上的同一文件夹中,并提供相似或相关的功能。

要使用Logger蓝图(类)创建一个新的记录器,我们只需使用记录器的名称,并将其设置为类名,然后我们传入__init__函数所需的任何参数。实例化日志记录器后,我们可以简单地使用对象名和相关的日志函数将日志消息写入正确的文件。

运行new_script_with_logger.py将在/var/log/class_logger.log文件中产生以下消息:

[INFO] This is an info message

清理干净

现在,您可能不希望为项目中需要写入一些日志消息的每个部分编写唯一的日志文件。因此,您想要的是以某种方式获取您已经创建的相同记录器(如果有的话),或者创建一个新的记录器(如果还没有的话)。

您希望保留面向对象编程的优点,同时将对象创建过程的控制权从使用 logger 的程序员手中夺走。我们通过控制创建logger对象的过程来做到这一点。

考虑以下控制流程的方式:

singleton_object.py

class SingletonObject(object):
  class __SingletonObject():
    def __init__(self):
      self.val = None

    def __str__(self):
      return "{0!r} {1}".format(self, self.val)

    # the rest of the class definition will follow here, as per the previous logging script

  instance = None

  def __new__(cls):
    if not SingletonObject.instance:
      SingletonObject.instance = SingletonObject.__SingletonObject()

    return SingletonObject.instance

  def __getattr__(self, name):
    return getattr(self.instance, name)

  def __setattr__(self, name):
    return setattr(self.instance, name)

别慌。

乍一看,这里似乎发生了很多事情。让我们浏览一下代码,看看这是如何导致类的单个实例被创建的,或者在类被实例化时被返回的。

房间里的大象是我们SingletonObject类内部的类定义。前导下划线告诉其他程序员这是一个私有类,他们不应该在原始类定义之外使用它。私有类是我们实现记录器功能的地方,这是留给你的,作为本章末尾的一个练习。出于示例的目的,这个类有一个名为val的对象属性和两个方法:__init__,它实例化对象,和__str__,当您在print语句中使用对象时调用它。

接下来,我们看到一个叫做类属性实例的东西,它对于从SingletonObject类实例化的所有对象都是固定的。该属性被设置为None,这意味着当脚本开始执行时,instance没有值。

另一件我们不习惯看到的事情是在__new__函数的定义中使用了cls参数而没有self参数。这是因为__new__是一个类方法。它不是将对象作为参数,而是将类作为参数接收,然后使用类定义来构造该类的新实例。

到目前为止,您已经看到了许多带有两个前导下划线和两个尾随下划线的方法。你需要知道的是,在你到目前为止遇到的情况下,这些都是用来表示这些是神奇的方法,是 Python 解释器使用的方法,不需要你显式调用它们。

__new__函数中,我们看到每当程序员试图实例化一个类型为SingletonObject的对象时,解释器会首先检查类变量instance,看看是否存在这样的实例。如果没有现有的实例,它会创建一个私有类的新实例,并将其赋给instance类变量。最后,__new__函数返回instance类变量中的类。

我们还修改了__getattr____setattr__函数来调用保存在instance类变量中的私有类的属性。这将调用传递给包含在instance变量中的对象,就好像外部对象具有属性一样。

设置self.val属性只是为了表明对象保持不变,即使脚本试图多次实例化它。

很好——现在您可以使用该类来验证单例实现是否如您所愿。

test_singleton.py

from singleton_object import SingletonObject

obj1 = SingletonObject()

obj1.val = "Object value 1"
print("print obj1: ", obj1)

print("-----")

obj2 = SingletonObject()
obj2.val = "Object value 2"
print("print obj1: ", obj1)
print("print obj2: ", obj2)

以下是输出:

print obj1:  <__main__.SingletonObject.__SingletonObject object at 0x7fda5524def0> Object value 1
-----
print obj1:  <__main__.SingletonObject.__SingletonObject object at 0x7fda5524def0> Object value 2
print obj2:  <__main__.SingletonObject.__SingletonObject object at 0x7fda5524def0> Object value 2

对单例模式的主要批评是,它只是获得全局状态的一种很好的方式,这是编写程序时要避免的事情之一。您希望避免全局状态的原因之一是,项目中某个部分的代码可能会改变全局状态,并在完全不相关的代码中导致意外结果。

也就是说,当项目的某些部分不影响代码的执行时,比如日志记录,使用全局状态是可以接受的。其他可能使用全局状态的地方包括缓存、负载平衡和路由映射。在所有这些情况下,信息都是单向流动的,单例实例本身是不可变的(它不会改变)。程序的任何部分都不会试图对单例进行更改,因此,由于共享状态,不存在项目的一部分干扰项目的另一部分的危险。

好了,你已经发现了你的第一个设计模式,叫做单例模式。恭喜你!

练习

  • 实现你自己的日志单例。
  • 如何使用单例模式创建日志记录器?

(鸣谢:singleton 模式代码模板的灵感来自于 http://python-3-patterns-idioms-test.readthedocs.io/en/latest/Singleton.html CC-BY-SA 上的 singleton 模式实现。)

三、原型模式

现实不在乎你是否相信它。——波巴·费特,星球大战扩展宇宙

问题

我仍然记得我第一次想写程序的时候。当时,DOS 一次不能处理超过 20 MB 的数据,因此我们巨大的 40 MB 硬盘必须分成两个驱动器。那是一辆米色的奥利维蒂 XT。当地图书馆有一个区,里面有计算机书籍。其中一个是非常薄的软皮封面,上面有一个游戏角色的图片。标题承诺教你编程你自己的游戏。在一个被虚假广告左右的年轻无知的经典案例中,我仔细检查了每一个例子,一个一个地打出字符(当时我看不懂英语,所以我真的不知道我在做什么)。大约第十次之后,我把所有的东西都正确地输入到了电脑自带的 GW Basic 界面中。我不知道如何保存和加载程序,所以每一个错误都意味着从头开始。我的巅峰时刻完全没有变化。我如此努力的“游戏”变成了一个简单的for循环和一个if语句。这个游戏的前提是你在一辆失控的汽车里,在汽车最终陷入水坝之前,你有三次机会猜测一个数字。就是这样——没有图形,没有声音,没有漂亮的颜色,只有三遍同样的文字问题,然后:“你死了。”做对了会产生一个简单的信息:“耶!你答对了。”

超越最初的步骤

尽管我的第一个程序完全令人失望,但当我第一次拿起这本书,并相信我可以编写一个游戏或任何我可以梦想的东西时,那神奇的时刻一直伴随着我。

我猜很多人最初对编程感兴趣是出于对游戏的兴趣和对编程游戏的渴望。可悲的是,游戏往往是庞大而复杂的系统,制作一个有趣而受欢迎的游戏是一项巨大的事业,没有任何成功的保证。最后,相对来说很少有程序员去追求自己对游戏编程的最初兴趣。在这一章中,我们将想象我们确实是这个被选择的程序员群体的一部分。

真实游戏的基础

假设我们想写一个类似星际争霸的 RTS(即时策略游戏的简称),你有一个玩家控制一群角色。玩家需要建造建筑,生成单位,并最终达到某种策略目标。让我们考虑一个单位,一个骑士。骑士是在一个叫做兵营的建筑里产生的。一个玩家可以在一个场景中拥有多个这样的建筑,以便更快地创建骑士单位。

看看这个单元的描述和它与建筑的交互,一个相当明显的解决方案是定义一个Barracks类,这个类有一个返回Knight对象的generate_knight函数,这个对象是Knight类的一个实例。我们将为Knight类实现以下基本属性:

  • 生活
  • 速度
  • 攻击力
  • 攻击范围
  • 武器

生成一个新的Knight实例只需要实例化一个来自Knight类的对象并设置值。

下面是实现这一点的代码:

rts_simple.py

class Knight(object):
  def __init__(
    self,
    life,
    speed,
    attack_power,
    attack_range,
    weapon
   ):
     self.life = life
     self.speed = speed
     self.attack_power = attack_power
     self.attack_range = attack_range
     self.weapon = weapon

  def __str__(self):
    return  "Life: {0}\n" \
            "Speed: {1}\n" \
            "Attack Power: {2}\n" \
            "Attack Range: {3}\n" \
            "Weapon: {4}".format(
     self.life,
     self.speed,
     self.attack_power,
     self.attack_range,
     self.weapon
    )

class Barracks(object):
  def generate_knight(self):
    return Knight(400, 5, 3, 1, "short sword")

if __name__ == "__main__":
  barracks = Barracks()
  knight1 = barracks.generate_knight()
  print("[knight1] {}".format(knight1))

从命令行运行这段代码将创建一个Barracks类的barracks实例,然后将使用该实例生成knight1,这只是一个Knight类的实例,其值是为我们在上一节中定义的属性设置的。运行代码将以下文本打印到终端,这样您就可以检查knight实例是否与您基于generate_knight函数中设置的值所期望的相匹配。

[knight1] Life: 400
Speed: 5
Attack Power: 3
Attack Range: 1
Weapon: short sword

即使我们没有任何绘制逻辑或交互代码,用一些默认值生成一个新的骑士也是相当容易的。在本章的其余部分,我们将关注这个生成代码,看看它教我们如何创建多个几乎完全相同的对象。

Note

如果你有兴趣学习更多关于游戏编程的知识,我挑战你看看PyGame包;有关更多信息,请参见本章的“练习”一节。

如果你以前从未玩过或看过 RTS,重要的是要知道这些游戏的很大一部分乐趣来自于你可以生成的许多不同的单位。每个单位都有自己的优势和劣势,您如何利用这些独特的特征决定了您最终采用的策略。你越善于理解权衡,你就越善于制定有效的策略。

下一步是增加一个可以由兵营生成的角色。例如,我打算添加一个Archer类,但是可以随意添加一些你自己的单元类型,赋予它们独特的优点和缺点。你可以创造你梦想中的演员阵容。当你这么做的时候,可以自由的思考你的单位需要增加游戏深度的其他属性。当你读完这一章时,仔细阅读你写的代码,并加入这些想法。这不仅会让你的 RTS 更有趣,还会帮助你更好地理解整章的论点。

添加了Archer类后,我们的rts_simple.py现在看起来像这样:

rts_simple.py

class Knight(object):
  def __init__(
    self,
    life,
    speed,
    attack_power,
    attack_range,
    weapon
   ):
     self.unit_type = "Knight"
     self.life = life
     self.speed = speed
     self.attack_power = attack_power
     self.attack_range = attack_range
     self.weapon = weapon

  def __str__(self):
    return  "Type: {0}\n" \
            "Life: {1}\n" \
            "Speed: {2}\n" \
            "Attack Power: {3}\n" \
            "Attack Range: {4}\n" \
            "Weapon: {5}".format(
       self.unit_type,
       self.life,
       self.speed,

       self.attack_power,
       self.attack_range,
       self.weapon
    )

class Archer(object):
  def __init__(
    self,
    life,
    speed,
    attack_power,
    attack_range,
    weapon
   ):
   self.unit_type = "Archer"
   self.life = life
   self.speed = speed
   self.attack_power = attack_power
   self.attack_range = attack_range
   self.weapon = weapon

  def __str__(self):
   return  "Type: {0}\n" \
           "Life: {1}\n" \
           "Speed: {2}\n" \
           "Attack Power: {3}\n" \
           "Attack Range: {4}\n" \
           "Weapon: {5}".format(
      self.unit_type,
      self.life,
      self.speed,

      self.attack_power,
      self.attack_range,
      self.weapon
   )

class Barracks(object):
  def generate_knight(self):
    return Knight(400, 5, 3, 1, "short sword")

  def generate_archer(self):
    return Archer(200, 7, 1, 5, "short bow")

if __name__ == "__main__":
  barracks = Barracks()  knight1 = barracks.generate_knight()  archer1 = barracks.generate_archer()
  print("[knight1] {}".format(knight1))
  print("[archer1] {}".format(archer1))

接下来,您将看到运行这个程序的结果。我们现在有一个骑士和一个弓箭手,每个都有自己独特的单位属性。在继续解释结果之前,通读代码并尝试理解每一行是做什么的。

[knight1] Type: Knight
Life: 400
Speed: 5

Attack Power: 3
Attack Range: 1
Weapon: short sword
[archer1] Type: Archer
Life: 200
Speed: 7
Attack Power: 1
Attack Range: 5
Weapon: short bow

目前,只有两个单位需要考虑,但是既然你已经有时间考虑你要添加到你的单位生成器中的所有其他单位,很明显,为你计划在特定建筑中生成的每种类型的单位设置单独的功能并不是一个好主意。为了强调这一点,想象一下如果你想升级一个兵营所能生产的单位会发生什么。举个例子,考虑升级Archer职业,这样它制造的武器不再是短弓而是长弓,它的攻击范围增加了 5 点,攻击力增加了 2 点。突然,你在Barracks类中增加了两倍的函数,你需要保存一些兵营和它能生成的单位的状态记录,以确保你生成了正确的单位等级。

警钟现在应该响了。

必须有一种更好的方法来实现单元生成,这种方法不仅知道您希望它生成的单元的类型,还知道所讨论的单元的级别。实现这一点的一种方法是用一种叫做generate_unit的方法来代替单独的generate_knightgenerate_archer方法。这个方法的参数是要生成的单元类型,以及您希望它生成的单元级别。single 方法将使用该信息来分割成要创建的单元。我们还应该扩展单个单元类,根据单元实例化时传递给构造函数的一个level参数,改变用于不同单元属性的参数。

升级后的单元生成代码将如下所示:

rts_multi_unit.py

class Knight(object):
  def __init__(self, level):
     self.unit_type = "Knight"
     if level == 1:
       self.life = 400
       self.speed = 5
       self.attack_power = 3
       self.attack_range = 1
       self.weapon = "short sword"
     elif level == 2:
       self.life = 400
       self.speed = 5
       self.attack_power = 6
       self.attack_range = 2
       self.weapon = "long sword"

  def __str__(self):
    return  "Type: {0}\n" \
            "Life: {1}\n" \
            "Speed: {2}\n" \
            "Attack Power: {3}\n" \
            "Attack Range: {4}\n" \
            "Weapon: {5}".format(
       self.unit_type,
       self.life,
       self.speed,
       self.attack_power,

       self.attack_range,
       self.weapon
    )

class Archer(object):
  def __init__(self, level):
    self.unit_type = "Archer"
    if level == 1:
      self.life = 200
      self.speed = 7
      self.attack_power = 1
      self.attack_range = 5
      self.weapon = "short bow"
    elif level == 2:
      self.life = 200
      self.speed = 7
      self.attack_power = 3
      self.attack_range = 10
      self.weapon = "long bow"

  def __str__(self):
   return  "Type: {0}\n" \
           "Life: {1}\n" \
           "Speed: {2}\n" \
           "Attack Power: {3}\n" \
           "Attack Range: {4}\n" \
           "Weapon: {5}".format(
      self.unit_type,
      self.life,
      self.speed,
      self.attack_power,
      self.attack_range,
      self.weapon
   )

class Barracks(object):
  def build_unit(self, unit_type, level):
    if unit_type == "knight":
      return Knight(level)
    elif unit_type == "archer":
      return Archer(level)

if __name__ == "__main__":
  barracks = Barracks()
  knight1 = barracks.build_unit("knight", 1)
  archer1 = barracks.build_unit("archer", 2)
  print("[knight1] {}".format(knight1))
  print("[archer1] {}".format(archer1))

在接下来的结果中,你会看到代码生成了一个 1 级骑士和一个 2 级弓箭手,他们的个人参数符合你对他们的期望,而不需要Barracks类来跟踪每个单位的每个等级以及与他们相关的相关参数。

[knight1] Type: Knight
Life: 400
Speed: 5
Attack Power: 3
Attack Range: 1
Weapon: short sword
[archer1] Type: Archer
Life: 200

Speed: 7
Attack Power: 3
Attack Range: 10
Weapon: long bow

与前一个实现相比,我更喜欢这个实现,因为我们减少了所需的方法,并在单元类中隔离了单元级参数,在那里拥有它们更有意义。

在现代 RTS 游戏中,平衡是一个大问题——游戏设计师面临的主要挑战之一。游戏平衡背后的想法是,有时用户会找到一种方法来利用一个特定单位的特性,以这种方式压倒游戏中的其他策略或情况。尽管这听起来像是你想要找到的策略类型,但这实际上保证了玩家会对你的游戏失去兴趣。或者,一些角色可能有弱点,这使得它在游戏中几乎没有用处。在这两种情况下,有问题的单位(或游戏整体)被认为是不平衡的。一个游戏设计者想要改变每个单位的参数(比如攻击力)来解决这些不平衡。

挖掘成千上万行代码来查找每个类的参数值并修改它们,特别是如果开发人员在整个开发生命周期中必须这样做数百次的话。想象一下,对于像 Eve Online 这样严重依赖 Python 作为游戏逻辑基础的游戏来说,在一行又一行的代码中挖掘会是一件多么混乱的事情。

我们可以将参数存储在一个单独的 JSON 文件或数据库中,以允许游戏设计者在一个地方改变单位参数。为游戏设计者创建一个漂亮的 GUI(图形用户界面)是很容易的,在这里他们可以快速方便地进行修改,甚至不需要在文本编辑器中修改文件。

当我们想要实例化一个单元时,我们加载相关的文件或条目,提取我们需要的值,然后像以前一样创建实例,就像这样:

knight_1.dat

400

5

3

1

short sword

archer_1.dat

200

7

3

10

Long bow

rts_file_based.py

class Knight(object):
  def __init__(self, level):
     self.unit_type = "Knight"

     filename = "{}_{}.dat".format(self.unit_type, level)

     with open(filename, 'r') as parameter_file:
       lines = parameter_file.read().split("\n")
       self.life = lines[0]
       self.speed = lines[1]
       self.attack_power = lines[2]
       self.attack_range = lines[3]
       self.weapon = lines[4]

  def __str__(self):
    return  "Type: {0}\n" \
            "Life: {1}\n" \
            "Speed: {2}\n" \
            "Attack Power: {3}\n" \
            "Attack Range: {4}\n" \
            "Weapon: {5}".format(
       self.unit_type,
       self.life,
       self.speed,

       self.attack_power,
       self.attack_range,
       self.weapon
    )

class Archer(object):

  def __init__(self, level):
    self.unit_type = "Archer"

    filename = "{}_{}.dat".format(self.unit_type, level)

    with open(filename, 'r') as parameter_file:
      lines = parameter_file.read().split("\n")
      self.life = lines[0]
      self.speed = lines[1]
      self.attack_power = lines[2]
      self.attack_range = lines[3]
      self.weapon = lines[4]

  def __str__(self):
   return  "Type: {0}\n" \
           "Life: {1}\n" \
           "Speed: {2}\n" \
           "Attack Power: {3}\n" \
           "Attack Range: {4}\n" \
           "Weapon: {5}".format(
      self.unit_type,
      self.life,
      self.speed,
      self.attack_power,
      self.attack_range,
      self.weapon
   )

class Barracks(object):
  def build_unit(self, unit_type, level):
    if unit_type == "knight":
      return Knight(level)
    elif unit_type == "archer":
      return Archer(level)

if __name__ == "__main__":
  baracks = Baracks()
  knight1 = barracks.build_unit("knight", 1)
  archer1 = barracks.build_unit("archer", 2)
  print("[knight1] {}".format(knight1))
  print("[archer1] {}".format(archer1))

因为单元数据文件以可预测的顺序存储数据,所以很容易从磁盘获取文件,然后读入构建相关单元所需的参数。代码仍然交付与以前相同的结果,但是现在我们准备平衡许多不同的单元类型和级别。在我们的例子中,从文件导入对于ArcherKnight类来说看起来是一样的,但是我们必须记住,我们将有一些单元必须从它们的文件中导入不同的参数,所以一个单独的导入文件在现实场景中是不切实际的。

您可以验证您的结果是否与这些结果匹配:

[knight1] Type: Knight
Life: 400
Speed: 5
Attack Power: 3
Attack Range: 1
Weapon: short sword
[archer1] Type: Archer
Life: 200
Speed: 7
Attack Power: 3
Attack Range: 10
Weapon: long bow

玩家将可以建造同一个建筑的多个版本,正如我们之前讨论过的,我们希望一个特定的建筑可以产生的单位的等级和类型可以根据建筑的等级而改变。一个 1 级兵营只能产生 1 级骑士,但是一旦兵营升级到 2 级,它就会释放弓箭手单位,作为额外的奖励,它现在可以产生 2 级骑士而不是以前的 1 级骑士。升级一个建筑只会影响它所能生产的单位,而不会影响玩家建造的所有同类建筑的能力。我们不能简单地跟踪一个单元的单个实例;现在每栋建筑都需要跟踪自己的单元版本。

每当一个建筑想要创建一个单元时,它需要查找它可以创建什么单元,然后发出命令来创建选定的单元。然后,单元类必须查询存储系统以找到相关的参数,然后在将这些参数传递给正在创建的实例的类构造函数之前,从存储中读取这些参数。这都是非常低效的。如果一栋建筑需要生成 500 个相同类型的单元,您必须对您选择的存储系统提出 499 个重复请求。用这个乘以建筑的数量,然后加上每栋建筑需要做的查找来决定它应该能够产生哪些单位。像 Eve Online 这样的大型游戏,或者任何其他现代即时策略游戏,如果每次需要生成一个单位或者建造一个建筑时都需要经过这个过程,那么它会在短时间内杀死你的系统。一些游戏更进了一步,允许在建筑上添加特定的附件,赋予建筑中的单位不同于普通单位的能力,这将使系统更加消耗资源。

我们在这里有一个非常真实的需求,即创建大量几乎相同的对象,通过一两个小的调整来区分它们。正如我们所看到的,每次从头开始加载这些对象并不是一个可伸缩的解决方案。

这就引出了。。。

实现原型模式

在原型模式中,我们更喜欢组合而不是继承。由部件组成的类允许您在运行时替换那些部件,这极大地影响了系统的可测试性和可维护性。要实例化的类是在运行时通过动态加载来指定的。原型模式的这一特征的结果是子分类显著减少。客户端看不到创建新实例的复杂性。所有这些都很棒,但是这种模式的主要好处是它迫使你去编程一个接口,这导致了更好的设计。

Note

只是要注意,带有循环引用的深度克隆类可能会导致问题。请参见下一节,了解有关浅层拷贝与深层拷贝的更多信息。

我们只想复制手头上的某个对象。要确保副本按其应有的方式设置,并将对象的功能与系统的其余部分隔离,您要复制的实例应该提供复制功能。实例上的clone()方法克隆对象,然后相应地修改它的值,这将是理想的。

原型模式需要的三个组件如下:

  • 客户端通过要求原型克隆自己来创建一个新对象
  • Prototype 声明了一个用于克隆自身的接口
  • 具体原型实现了克隆自身的操作

(原型模式的树形组件: http://radek.io/2011/08/03/design-pattern-prototype/ )

在 RTS 的例子中,每个建筑都应该保留一个原型列表,它可以用它来生成单位,就像一个相同级别的单位列表,其属性与建筑的当前状态相匹配。当建筑物升级时,该列表会更新以匹配建筑物的新功能。我们要从等式中去掉 499 个多余的电话。这也是原型设计模式偏离抽象工厂设计模式的地方,我们将在本书的后面讨论这一点。通过交换建筑在运行时可以使用的原型,我们允许建筑动态地切换到生成完全不同的单元,而不需要对正在讨论的建筑类进行任何形式的子类化。我们的建筑实际上成为了原型管理器。

原型模式的想法很棒,但是在我们可以在我们的建筑中实现该模式之前,我们需要再看一个概念。

浅层拷贝与深层拷贝

Python 处理变量的方式与您可能遇到的其他编程语言略有不同。除了像整数和字符串这样的基本类型,Python 中的变量更接近于标记或标签,而不是其他编程语言用来比喻的桶。Python 变量本质上是指向存储相关值的内存地址的指针。

让我们看看下面的例子:

a = range(1,6)
print("[a] {}".format(a))
b = a
print("[b] {}".format(b))
b.append(6)
print("[a] {}".format(a))
print("[b] {}".format(b))

第一条语句将变量a指向从 1 到 6(不包括 6)的数字列表。接下来,变量b被分配给a所指向的同一个数字列表。最后,数字 6 被添加到由b指向的列表的末尾。

您预计print陈述的结果会是什么?看看下面的输出。它符合你的期望吗?

[a] [1, 2, 3, 4, 5]
[b] [1, 2, 3, 4, 5]
[a] [1, 2, 3, 4, 5, 6]
[b] [1, 2, 3, 4, 5, 6]

大多数人感到惊讶的是,数字 6 被附加到列表a指向的和列表b指向的末尾。如果你记得事实上ab指向同一个列表,很明显,添加一个元素到列表的末尾应该显示添加到列表末尾的元素,不管你在看哪个变量。

浅拷贝

当我们想要制作一个列表的实际副本,并在过程结束时拥有两个独立的列表时,我们需要使用不同的策略。

就像之前一样,我们将把相同的数字加载到一个列表中,并将变量a指向该列表。这一次,我们将使用 slice 操作符来复制列表。

a = range(1,6)
print("[a] {}".format(a))
b = a[:]
print("[b] {}".format(b))
b.append(6)
print("[a] {}".format(a))
print("[b] {}".format(b))

slice 操作符是一个非常有用的工具,因为它允许我们以许多不同的方式分割一个列表(或字符串)。想从一个列表中获取所有的元素,而忽略前两个元素吗?没问题——只需用[2:]分割列表。除了最后两个元素之外的所有元素呢?切片做的:[:-2]。您甚至可以要求 slice 操作符只给你每隔一个元素。我向你挑战,看你是如何做到的。

当我们将b赋值给a[:]时,我们告诉b指向通过复制列表a中的元素创建的切片,从第一个到最后一个元素,有效地将列表a中的所有值复制到内存中的另一个位置,并将变量b指向该列表。

你看到的结果让你惊讶吗?

[a] [1, 2, 3, 4, 5]
[b] [1, 2, 3, 4, 5]
[a] [1, 2, 3, 4, 5]
[b] [1, 2, 3, 4, 5, 6]

slice 操作符在处理浅层列表(只包含实际值的列表,不包含对列表或字典等其他复杂对象的引用)时非常有效。

处理嵌套结构

看看下面的代码。你认为结果会是什么?

lst1 = ['a', 'b', ['ab', 'ba']]
lst2 = lst1[:]
lst2[0] = 'c'
print("[lst1] {}".format(lst1))
print("[lst2] {}".format(lst2))

在这里,您可以看到一个深度列表的示例。看一看结果——它们与你的预测相符吗?

[lst1] ['a', 'b', ['ab', 'ba']]
[lst2] ['c', 'b', ['ab', 'ba']]

正如您可能已经从浅层复制示例中预料到的,改变lst2的第一个元素并不会改变lst1的第一个元素,但是当我们改变列表中的一个元素时会发生什么呢?

lst1 = ['a', 'b', ['ab', 'ba']]
lst2 = lst1[:]
lst2[2][1] = 'd'
print("[lst1] {}".format(lst1))
print("[lst2] {}".format(lst2))

你能解释我们这次得到的结果吗?

[lst1] ['a', 'b', ['ab', 'd']]
[lst2] ['a', 'b', ['ab', 'd']]

你对发生的事情感到惊讶吗?

列表lst1包含三个元素,'a''b',以及一个指向另一个列表的指针,看起来像这样:['ab', 'ba']。当我们对lst1进行浅层复制来创建lst2指向的列表时,只有列表中某一层的元素被复制。未克隆包含在lst1中位置 2 的元素地址处的结构;只有指向内存中列表['ab', 'ba']位置的值。结果,lst1lst2都指向包含字符'a''b'的单独列表,后面是指向包含['ab', 'ba']的同一列表的指针,这将导致每当某个函数更改该列表中的元素时都会出现问题,因为它会影响另一个列表的内容。

深层拷贝

显然,在克隆对象时,我们需要另一种解决方案。我们如何才能强迫 Python 对列表及其子列表或对象中包含的所有内容进行完整的复制,即深度复制?幸运的是,我们将copy模块作为标准库的一部分。copy包含一个方法deep-copy,它允许一个任意列表的完整深度拷贝;即浅等列表。我们现在将使用深度拷贝来修改前面的示例,以便获得我们期望的输出。

from copy import deepcopy

lst1 = ['a', 'b', ['ab', 'ba']]
lst2 = deepcopy(lst1)
lst2[2][1] = 'd'
print("[lst1] {}".format(lst1))
print("[lst2] {}".format(lst2))

导致:

[lst1] ['a', 'b', ['ab', 'ba']]
[lst2] ['a', 'b', ['ab', 'd']]

那里。现在我们有了预期的结果,经过了相当长的一段弯路,我们准备看看这对 RTS 中的建筑意味着什么。

利用我们在项目中学到的知识

在本质上,原型模式只是一个clone()函数,它接受一个对象作为输入参数,并返回它的一个克隆。

原型模式实现的框架应该声明一个指定纯虚拟clone()方法的抽象基类。任何需要多态构造函数(该类根据实例化时收到的参数数量决定使用哪个构造函数)功能的类都从抽象基类中派生出来,并实现clone()方法。每个单元都需要从这个抽象基类中派生出来。客户机调用原型上的clone()方法,而不是编写调用硬编码类名上的new操作符的代码。

一般来说,原型模式应该是这样的:

prototype_1.py

from abc import ABCMeta, abstractmethod

class Prototype(metaclass=ABCMeta):
  @abstractmethod
  def clone(self):
    pass

concrete.py

from prototype_1 import Prototype
from copy import deepcopy

class Concrete(Prototype):
  def clone(self):
    return deepcopy(self)

最后,我们可以用原型模式实现我们的单元生成构建,使用相同的prototype_1.py文件。

rts_prototype.py

from prototype_1 import Prototype
from copy import deepcopy

class Knight(Prototype):
  def __init__(self, level):
     self.unit_type = "Knight"

     filename = "{}_{}.dat".format(self.unit_type, level)

     with open(filename, 'r') as parameter_file:
       lines = parameter_file.read().split("\n")
       self.life = lines[0]
       self.speed = lines[1]
       self.attack_power = lines[2]
       self.attack_range = lines[3]
       self.weapon = lines[4]

  def __str__(self):
    return  "Type: {0}\n" \
            "Life: {1}\n" \
            "Speed: {2}\n" \
            "Attack Power: {3}\n" \
            "Attack Range: {4}\n" \
            "Weapon: {5}".format(
       self.unit_type,
       self.life,
       self.speed,
       self.attack_power,
       self.attack_range,
       self.weapon
    )

  def clone(self):
    return deepcopy(self)

class Archer(Prototype):

  def __init__(self, level):
    self.unit_type = "Archer"

    filename = "{}_{}.dat".format(self.unit_type, level)

    with open(filename, 'r') as parameter_file:
      lines = parameter_file.read().split("\n")
      self.life = lines[0]
      self.speed = lines[1]
      self.attack_power = lines[2]
      self.attack_range = lines[3]
      self.weapon = lines[4]

  def __str__(self):
   return  "Type: {0}\n" \
           "Life: {1}\n" \
           "Speed: {2}\n" \
           "Attack Power: {3}\n" \
           "Attack Range: {4}\n" \
           "Weapon: {5}".format(
      self.unit_type,
      self.life,
      self.speed,
      self.attack_power,
      self.attack_range,
      self.weapon
   )

  def clone(self):
   return deepcopy(self)

class Barracks(object):
  def __init__(self):
    self.units = {
      "knight": {
        1: Knight(1),
        2: Knight(2)
      },
      "archer": {
        1: Archer(1),
        2: Archer(2)
      }
    }

  def build_unit(self, unit_type, level):
    return self.units[unit_type][level].clone()

if __name__ == "__main__":
  barracks = Baracks()
  knight1 = barracks.build_unit("knight", 1)
  archer1 = barracks.build_unit("archer", 2)
  print("[knight1] {}".format(knight1))
  print("[archer1] {}".format(archer1))

当我们在单位类中扩展抽象基类时,这迫使我们实现了clone方法,我们在让兵营生成新单位时使用了这个方法。我们做的另一件事是生成一个Barracks实例可以生成的所有选项,并将它保存在一个单元的数组中。现在,我们可以简单地用正确的级别克隆这个单元,而不需要打开一个文件或者从外部数据源加载任何数据。

[knight1] Type: Knight
Life: 400
Speed: 5
Attack Power: 3
Attack Range: 1
Weapon: short sword
[archer1] Type: Archer
Life: 200
Speed: 7
Attack Power: 3
Attack Range: 10
Weapon: long bow

干得好;你不仅迈出了创建你自己的 RTS 的第一步,而且你还深入挖掘了一个非常有用的设计模式。

练习

  • 以原型示例代码为例,扩展Archer类来处理它的升级。
  • 尝试在兵营建筑中增加更多的单位。
  • 添加第二种类型的建筑。
  • 看看 PyGame 包( http://pygame.org/hifi.html )。如何扩展你的Knight类,使它能在游戏循环中绘制自己?
  • 作为一个练习,您可以查看 PyGame 包并为Knight类实现一个draw()方法,以便能够在地图上绘制它。
  • 如果你有兴趣,试着用 PyGame 写一个自己的迷你 RTS 游戏,里面有一个兵营和两个单位。
  • 扩展每个单元的clone方法,生成一个随机名称,这样每个克隆的单元都会有一个不同的名称。

四、工厂模式

质量意味着在没人注意的时候把事情做好。—亨利·福特

在第三章中,你开始考虑编写自己的游戏。为了确保你不会被一个纯文本的“游戏”所欺骗,让我们花一点时间来看看在屏幕上画些什么。在这一章中,我们将接触到使用 Python 制作图形的基础知识。我们将使用PyGame套装作为我们的首选武器。我们将创建工厂类。工厂类定义了接受一组特定参数的对象,并使用这些参数来创建其他类的对象。我们还将定义抽象工厂类,作为构建这些工厂类的模板。

入门指南

在虚拟环境中,您可以使用以下命令使用 pip 安装 PyGame:

pip install pygame

这应该是相当无痛的。现在,要获得一个实际的窗口:

graphic_base.py

import pygame

pygame.init()
screen = pygame.display.set_mode((800, 600))

保存graphic_base.py并运行文件:

python graphic_base.py

一个 400 像素宽、300 像素高的空白窗口会在你的屏幕上弹出,然后立刻消失。恭喜你,你已经创建了你的第一个屏幕。显然,我们希望屏幕停留的时间长一点,所以我们只需添加一个sleep函数to graphic_base.py

graphic_base.py

import pygame
from time import sleep

pygame.init()
screen = pygame.display.set_mode((800, 600))

sleep(10)

time包(标准库的一部分)中,我们导入了sleep函数,该函数在给定的秒数内暂停执行。在脚本的末尾添加了一个十秒钟的睡眠,在脚本完成执行和窗口消失之前,窗口保持打开十秒钟。

当我第一次在屏幕上创建一个窗口时,我非常兴奋,但当我叫我的室友过来给他看我的窗口时,他完全不感兴趣。我建议你在展示你的新作品之前在窗户上加点东西。扩展graphic_base.py向窗口添加一个正方形。

graphic_base.py

import pygame
import time

pygame.init()
screen = pygame.display.set_mode((800,600))

pygame.draw.rect(screen, (255, 0, 34), pygame.Rect(42, 15, 40, 32))
pygame.display.flip()

time.sleep(10)

pygame.draw.rect函数为指向你的窗口的screen变量画一个矩形。第二个参数是包含用于填充形状的颜色的元组,最后,pygame 矩形与左上角和右下角的坐标一起传入。您在颜色元组中看到的三个值组成了所谓的 RGB 值(红绿蓝),每个分量都是 255 中的一个值,它指示最终颜色混合中分量颜色的强度。

如果省略pygame.display.flip(),则不会显示任何形状。这是因为 PyGame 在内存缓冲区中绘制屏幕,然后将整个图像翻转到活动屏幕上(这就是您所看到的)。每次你更新显示时,你必须调用pygame.display.flip()让变化显示在屏幕上。

尝试在屏幕上用多种颜色绘制不同的矩形。

游戏循环

游戏编程中最基本的概念叫做游戏循环。这个想法是这样工作的:游戏检查用户的一些输入,用户做一些计算来更新游戏的状态,然后给玩家一些反馈。在我们的例子中,我们只是更新玩家在屏幕上看到的东西,但是你可以包括声音或触觉反馈。这种情况反复发生,直到玩家退出。每次屏幕更新时,我们运行pygame.display.flip()函数向玩家显示更新后的显示。

游戏的基本结构如下:

  • 进行一些初始化,例如设置窗口和屏幕上元素的初始位置和颜色。
  • 当用户不退出游戏时,运行游戏循环。
  • 当用户退出时,终止窗口。

在代码中,它可能是这样的:

graphic_base.py

import pygame

window_dimensions = 800, 600
screen = pygame.display.set_mode(window_dimensions)

player_quits = False

while not player_quits:
  for event in pygame.event.get():
    if event.type == pygame.QUIT:
      player_quits = True

  pygame.display.flip()

此时,这段代码什么也不做,只是等待玩家点击窗口上的关闭按钮,然后终止执行。为了使它更具交互性,让我们添加一个小方块,并让它在用户按下其中一个箭头键时移动。

为此,我们需要在屏幕上的初始位置画一个正方形,准备好对箭头键事件做出反应。

shape_game.py(见 ch04_03.py)

import pygame

window_dimensions = 800, 600
screen = pygame.display.set_mode(window_dimensions)

x = 100
y = 100

player_quits = False

while not player_quits:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            player_quits = True

        pressed = pygame.key.get_pressed()
        if pressed[pygame.K_UP]: y -= 4
        if pressed[pygame.K_DOWN]: y += 4
        if pressed[pygame.K_LEFT]: x -= 4
        if pressed[pygame.K_RIGHT]: x += 4

        screen.fill((0, 0, 0))
        pygame.draw.rect(screen, (255, 255, 0), pygame.Rect(x, y, 20, 20))

    pygame.display.flip()

稍微试验一下代码,看看是否可以让块不移动到窗口的边界之外。

现在你的积木可以在屏幕上移动了,那么做一圈怎么样?然后形成一个三角形。现在是游戏角色图标。。。你明白了。突然,大量显示代码堵塞了游戏循环。如果我们使用面向对象的方法来解决这个问题,会怎么样呢?

shape_game.py

import pygame

class Shape(object):
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def draw(self):
        raise NotImplementedError()

    def move(self, direction):
        if direction == 'up':
            self.y -= 4
        elif direction == 'down':
            self.y += 4
        elif direction == 'left':
            self.x -= 4
        elif direction == 'right':
            self.x += 4

class Square(Shape):
    def draw(self):
        pygame.draw.rect(
            screen,
            (255, 255, 0),
            pygame.Rect(self.x, self.y, 20, 20)
        )

class Circle(Shape):
    def draw(self):
        pygame.draw.circle(
            screen,
            (0, 255, 255),
            (selfx, self.y),
            10
        )

if __name__ == '__main__':
    window_dimensions = 800, 600
    screen = pygame.display.set_mode(window_dimensions)

    square = Square(100, 100)
    obj = square

    player_quits = False

    while not player_quits:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                player_quits = True

            pressed = pygame.key.get_pressed()
            if pressed[pygame.K_UP]: obj.move('up')
            if pressed[pygame.K_DOWN]: obj.move('down')
            if pressed[pygame.K_LEFT]: obj.move('left')
            if pressed[pygame.K_RIGHT]: obj.move('right')

            screen.fill((0, 0, 0))
            obj.draw()

        pygame.display.flip()

既然你现在已经有了圆形和方形的对象,考虑一下你将如何改变程序,以便当你按下“C”键时,屏幕上的对象变成一个圆形(如果它现在不是一个的话)。同样,当你按下“S”键时,形状会变成正方形。看看在游戏循环中使用对象比处理所有这些要容易得多。

Tip

看看 PyGame 的按键绑定以及本章中的代码。

仍然有一两个我们可以实现的改进,比如抽象出像movedraw这样每个类都必须发生的事情,这样我们就不必跟踪我们正在处理的是什么形状。我们希望能够引用一般的形状,只告诉它画自己,而不用担心它是什么形状(或者如果它是一个形状,开始,而不是一些图像或甚至一帧动画)。

显然,多态性不是一个完整的解决方案,因为每当我们创建一个新对象时,我们都必须不断更新代码,在一个大型游戏中,这种情况会发生在许多地方。问题是新类型的创建,而不是这些类型的使用。

既然你想写更好的代码,当你试图想出一个更好的方法来处理我们想要添加到变形人游戏中的扩展时,想想好代码的以下特征。

好的代码是

  • 易于维护,
  • 易于更新,
  • 易于扩展,并且
  • 很清楚它想要完成什么。

好的代码应该让你几周前写的东西变得尽可能的简单。你最不希望的就是在你害怕工作的代码中创建这些区域。

我们希望能够通过一个公共接口来创建对象,而不是将创建代码分散到整个系统中。这将本地化在您更新可以创建的形状类型时需要更改的代码。由于添加新类型是您最有可能对系统进行的添加,因此这是您在代码改进方面必须首先关注的领域之一是有意义的。

创建集中式对象创建系统的一种方法是使用工厂模式。这种模式有两种不同的方法,我们将同时介绍这两种方法,从更简单的工厂方法开始,然后转到抽象工厂实现。我们也将看看如何根据我们的游戏框架来实现这些。

在我们进入工厂模式之前,我希望你注意到原型模式和工厂模式之间有一个主要的区别。原型模式不需要子类化,但是需要一个initialize操作,而工厂模式需要子类化,但是不需要初始化。每一种都有自己的优势和你应该选择的地方,通过本章的学习,这种区别会变得更加清晰。

工厂方法

当我们想调用一个方法,传入一个字符串并获得一个新对象的返回值时,我们实际上是在调用一个工厂方法。对象的类型由传递给方法的字符串确定。

这使得通过允许您向软件添加功能来扩展您编写的代码变得容易,这是通过添加新的类并扩展工厂方法以接受新的字符串并返回您创建的类来完成的。

让我们看一下工厂方法的一个简单实现。

shape_ factory.py

import pygame

class Shape(object):
  def __init__(self, x, y):
      self.x = x
      self.y = y

  def draw(self):
      raise NotImplementedError()

  def move(self, direction):
      if direction == 'up':
          self.y -= 4
      elif direction == 'down':
          self.y += 4
      elif direction == 'left':
          self.x -= 4
      elif direction == 'right':
          self.x += 4

  @staticmethod  
  def factory(type):
    if type == "Circle":
      return Circle(100, 100)
    if type == "Square":
      return Square(100, 100)

    assert 0, "Bad shape requested: " + type

class Square(Shape):
    def draw(self):
        pygame.draw.rect(
            screen,
            (255, 255, 0),
            pygame.Rect(self.x, self.y, 20, 20)
        )

class Circle(Shape):
    def draw(self):
        pygame.draw.circle(
            screen,
            (0, 255, 255),
            (selfx, self.y),
            10
        )

if __name__ == '__main__':
    window_dimensions = 800, 600
    screen = pygame.display.set_mode(window_dimensions)

    obj = Shape.factory("square")

    player_quits = False

    while not player_quits:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                player_quits = True

            pressed = pygame.key.get_pressed()
            if pressed[pygame.K_UP]: obj.move('up')
            if pressed[pygame.K_DOWN]: obj.move('down')
            if pressed[pygame.K_LEFT]: obj.move('left')
            if pressed[pygame.K_RIGHT]: obj.move('right')

            screen.fill((0, 0, 0))
            obj.draw()

        pygame.display.flip()

修改上面的这段代码,使其从正方形变成圆形,或者变成您想要的任何其他形状或图像,会容易多少呢?

工厂方法的一些拥护者建议所有的构造函数都应该是私有的或受保护的,因为无论是创建一个新对象还是回收一个旧对象,对该类的用户来说都无关紧要。其思想是将对象的请求与其创建分离开来。

这个想法不应该作为教条来遵循,而是在你自己的项目中尝试一下,看看你从中获得了什么好处。一旦你习惯了使用工厂方法,可能还有工厂模式,你就可以自由地使用你自己的判断力来判断模式在你手头的项目中的有用性。

每当你在游戏中添加一个需要在屏幕上绘制的新类时,你只需要改变factory()方法。

当我们需要不同种类的工厂时会发生什么?也许你想加入音效工厂或环境元素而不是玩家角色工厂?您希望能够从同一个基本工厂创建不同类型的工厂子类。

抽象工厂

当您想要创建一个单一的接口来访问整个工厂集合时,您可以放心地使用抽象工厂。集合中的每个抽象工厂都需要实现一个预定义的接口,该接口上的每个函数都根据工厂方法模式返回另一个抽象类型。

import abc

class AbstractFactory(object):
  __metaclass__ = abc.ABCMeta

  @abc.abstractmethod
  def make_object(self):
    return

class CircleFactory(AbstractFactory):
  def make_object(self):
    # do something

    return Circle()

class SquareFactory(AbstractFactory):
  def make_object(self):
    # do something

    return Square()

这里,我们使用了内置的abc模块,它允许您定义一个抽象基类。在这个例子中,抽象工厂定义了定义具体工厂的蓝图,然后具体工厂创建圆形和方形。

Python 是动态类型的,所以不需要定义公共基类。如果我们想让代码更 pythonic 化,我们会看到这样的内容:

class CircleFactory(AbstractFactory):
  def make_object(self):
    # do something

    return Circle()

class SquareFactory(AbstractFactory):
  def make_object(self):
    # do something

    return Square()

def draw_function(factory):
  drawable = factory.make_object()
  drawable.draw()

def prepare_client():
  squareFactory = SquareFactory()
  draw_function(squareFactory)

  circleFactory = CircleFactory()
  draw_function(circleFactory)

在我们到目前为止设置的准系统游戏中,您可以想象工厂生成包含一个play()方法的对象,您可以在游戏循环中运行该方法来播放声音、计算移动或在屏幕上绘制形状和图像。抽象工厂的另一个常见用途是为不同操作系统的 GUI 元素创建工厂。它们都使用相同的基本功能,但是所创建的工厂是根据程序运行的操作系统来选择的。

恭喜你!你刚刚提升了你写伟大代码的能力。使用抽象工厂,您可以编写更容易修改和测试的代码。

摘要

当开发软件时,你想要防止一种令人烦恼的感觉,即你应该构建一些能够迎合每一个可能的未来事件的东西。尽管考虑你的软件的未来是好的,但是试图构建一个如此通用的软件来解决每一个可能的未来需求通常是徒劳的。最简单的原因是没有办法预见你的软件将走向何方。我并不是说你应该太天真,不考虑你的代码的未来,但是作为一名软件开发人员,能够改进你的代码以包含新的功能是你将学到的最有价值的技能之一。总是有一种诱惑,要扔掉你以前写的所有旧代码,从头开始“把它做好”这是一个梦想,一旦你“做对了”,你就会学到一些新的东西,让你的代码看起来不那么漂亮,所以你将不得不重做一切,永远不会完成任何事情。

总的原则是 YAGNI;在您的软件开发职业生涯中,您可能会遇到这个缩写。这是什么意思?你不需要它!原则是您应该编写能很好地解决当前问题的代码,并且只在需要时修改它来解决后续问题。

这就是为什么许多软件设计开始使用更简单的工厂方法,只有当开发人员发现哪里需要更多的灵活性时,他才会发展程序来使用抽象工厂、原型或构建器模式。

练习

  • 添加通过按键在圆形、三角形和正方形之间切换的功能。
  • 尝试实现一个图像对象,而不仅仅是一个形状。
  • 增强您的图像对象类,以便在分别向上、向下、向左和向右移动时,在要绘制的独立图像之间进行切换。
  • 作为一个挑战,尝试在移动时给图像添加动画。