书名:Python面向对象编程指南
ISBN:978-7-115-40558-6
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
Steven F. Lott的编程生涯开始于20世纪70年代,那时候计算机体积很大、昂贵并且非常少见。作为软件工程师和架构师,他参与了100多个不同规模的项目研发。在使用Python解决业务问题方面,他已经有10多年的经验了。
Steven目前是自由职业者,居住在美国东海岸。他的技术博客是:http://slott-softwarearchitect.blogspot.com。
本书特色
本书适合那些对Python面向对象的基础知识有一定掌握的读者。对于想要写出有一定复杂度且能与Python无缝结合的代码的读者,本书也是其不二之选。如果读者具备计算机科学的专业背景或者对常见的设计模式有一定的使用经验,将更加有助于对本书内容的学习。
Copyright ©2014 Packt Publishing. First published in the English language under the title Mastering Object-oriented Python.
All rights reserved.
本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。
版权所有,侵权必究。
Python是一种面向对象、解释型的程序设计语言,它已经被成功应用于科学计算、数据分析以及游戏开发等诸多领域。
本书深入介绍Python语言的面向对象特性,全书分3个部分共18章。
第1部分讲述用特殊方法实现Python风格的类,分别介绍了__init__()
方法、与Python无缝集成——基本特殊方法、属性访问和特性及修饰符、抽象基类设计的一致性、可调用对象和上下文的使用、创建容器和集合、创建数值类型、装饰器和mixin——横切方面;
第2部分讲述持久化和序列化,分别介绍了序列化和保存、用Shelve保存和获取对象、用SQLite保存和获取对象、传输和共享对象、配置文件和持久化;
第3部分讲述测试、调试、部署和维护,分别介绍了Logging和Warning模块、可测试性的设计、使用命令行、模块和包的设计、质量和文档。
本书深入剖析Python,帮助读者全面掌握Python并构建出更好的应用程序,非常适合对Python语言有一定了解并想要深入学习Python的读者,也适合有一定开发经验并且想要尝试使用Python语言进行编程的IT从业人员。
张心韬 新加坡国立大学系统分析硕士,北京航空航天大学北海学院软件工程学士。曾经就职于NEC(新加坡)和MobileOne(新加坡),目前投身金融领域,就职于GoSwiff(新加坡),担任.NET软件工程师,负责支付系统的研发工作。
他在编程领域耕耘数年,涉猎甚广,但自认“既非菜鸟,也非高人”。目前长期专注于.NET平台,对Python也甚为喜爱。业余时间爱好甚广,尤其喜欢学习中医知识,对时间管理、经济和历史也略有涉猎。
兰亮 北京航空航天大学北海学院软件工程学士,IT行业一线“码农”,曾获评“微软2014年度MVP”和“微软2015年度MVP”。曾一度混迹于飞信(中国)、NEC(新加坡)和MobileOne(新加坡),现就职于Keritos(新加坡),从事在线游戏研发工作。
他虽然涉猎广泛,但钟爱开源,长期关注前沿技术,并且对算法、函数式编程、设计模式以及IT文化等有着浓厚兴趣。工作之余,他喜欢在Coursera蹭课。作为一个热爱生活的人,他在钻研技术之余,还喜欢健身、旅行,立志成为一个阳光、向上的“码农”。
本书主要介绍Python语言的高级特性,特别是如何编写高质量的Python程序。这通常意味着编写高性能且拥有良好可维护性的程序。同时,我们也会探究不同的设计方案并确定究竟是哪种方案提供了最佳性能。而对于一些正在寻找解决方案的问题,这也是一种很好的方式。
本书的大部分内容将介绍一种给定设计的不同替代方案。一些方案性能更好,另一些方案更加简单或者更加适合于特定领域的问题。最重要的是,找到最好的算法和最优的数据结构,以最少的开销换取最大的价值。时间就是金钱,高效的程序会为它们的用户创造更多的价值。
Python的很多内部特性都可以直接被应用程序所使用。这意味着,我们的程序可以与Python现有的特性非常好地整合。充分利用这些Python特性,可以让我们的面向对象设计整合得很好。
我们经常会为一个问题寻找多种不同的解决方案。当你评估不同的算法和数据结构时,通常会设计几种不同的方案,它们在性能和内存的使用上不尽相同。通过评估不同的方案,最终合理地优化应用程序,这是一种重要的面向对象设计技巧。
本书一个更为重要的主题是,对于任何问题,没有所谓的唯一且最好的方法。相反,会有许多不同的方案,而这些方案也各有优劣。
关于编程风格的主题非常有趣。敏锐的读者会注意到,在一些非常细微的部分,例如在名称选择和符号的使用上,并非所有的例子都完全符合PEP-8。
随着你能够越来越熟练地以面向对象的方式使用Python,也将不得不花大量的时间去阅读各种Python源码。你会发现,甚至在Python标准库的模块中,都有很大的可变性。相比于展示完全一致的例子,我们更倾向于去关注那些不一致的部分,正如我们在各种开源项目中所看到的,一致性的缺乏,正是对代码更好的认可。
我们会用一些章节深入讲解Python的3个高级主题。
unittest
、doctest
、docstrings
以及一些特殊的函数名。第1部分“用特殊方法实现Python风格的类”,这个部分着重讲解面向对象编程以及如何更好地将Python内置的特性和我们的类进行集成,这个部分包括以下8章。
__init()__
方法”,详细讲解了__init()__
的功能和实现,我们会用不同的方式初始化一些简单的对象。接着,我们会尝试初始化更加复杂的对象,例如集合和容器。collections.abc
模块中的抽象基类。我们会探讨collections和containers的基本概念,主要关注那些常被扩展和修改的部分。类似地,我们还会探讨numbers的基本概念,主要关注那些常被实现的部分。contextlib
提供的方法以不同的方式来创建上下文管理器。我们会讲解可调用对象的一系列不同设计以及为什么有时候一个有状态的可调用对象会比一个简单的函数更加有用。在我们定制自己的上下文管理器之前,我们还会探讨如何使用Python中内置的上下文管理器。第2部分“持久化和序列化”介绍一个序列化到存储介质的持久化对象,它可能是转换为JSON后写入文件系统的,也可能是通过ORM存储到数据库的。这个部分会着重探讨持久化的不同方法,包括以下5章。
第3部分“测试、调试、部署和维护”,我们会展示如何收集数据来支持和调试高性能程序。其中包括创建尽可能完善的文档——减少技术支持的难度。这个部分包括最后5章。
logging
和warning
模块来记录审计和调试信息。相比于使用print()
函数,这将是巨大的进步。unittest
和doctest
。argparse
模块解析选项和参数。接着,我们会使用命令模式来编写易于整合和扩展的程序模块,而不是使用纯粹的shell脚本。你需要下面的软件来编译和运行本书中的示例。
PyYaml
、SQLAlchemy
和Jinja2
。
本书主要讲述Python的高级主题,所以要求读者熟悉Python 3。通过解决大型的复杂问题,你将会获益良多。
如果你非常熟悉其他的编程语言,但是想切换到Python,那么你可能会发现本书对你很有帮助。本书不会介绍诸如语法之类的基本概念。
对于熟悉Python 2的程序员,本书可以帮助你切换到Python 3。我们不会涉及任何版本切换的工具(例如,从版本2升级到版本3),以及任何共用的库(例如six)。书中重点讲述Python 3带来的新开发方式。
在本书中,你会发现我们使用不同样式的文字来区分不同类别的信息,下面是这些样式的一些例子。
文本中涉及源码的单词会用以下这种样式:“我们可以通过import
来使用Python的其他模块”。
代码块的样式如下所示:
class Friend(Contact):
def __init__(self, name, email, phone):
self.name = name
self.email = email
self.phone = phone
当我们想提醒你注意一个代码块的特定部分时,我们对该部分使用粗体:
class Friend(Contact):
def __init__(self, name, email, phone):
self.name = name
self.email = email
self.phone = phone
以下是一个从命令行进行输入和输出的例子:
>>> e = EmailableContact("John Smith", "jsmith@example.net") >>>Contact.all_contacts
新术语和重要的文字以粗体显示。在屏幕上看到的字,例如在菜单或对话框中出现的文字显示效果为:“我们通过这个功能来实现在每次单击Roll按钮时,在标签中显示一个新的随机值”。
警告或重要信息会以这样的形式显示。 |
提示和技巧会以这样的形式显示。 |
非常欢迎读者对本书提供反馈和建议。让我们知道你关于本书的看法——你所喜欢和不喜欢的部分。哪部分内容使读者获得了最大收获,了解这点对于我们是重要的。
如果你要为我们提供反馈的话,只需要发送邮件到feedback@packpub.com,并在消息标题中包含书名。
如果你有推荐让我们出版的书,请发送邮件至suggest@packtpub.com。
如果有一个你所擅长的主题并有兴趣写书,可以联系www.packtpub.com/authors。
作为Packt图书的主人,我们会尽力为你提供帮助。
你可以从此处下载所有你通过 Packt 账号支付的 Packt 图书的示例代码:http://www.PacktPub.com。如果你是通过其他方式支付的,可以访问http://www.PacktPub.com/support并进行注册,我们将通过邮件的方式发送给你。
我们已经尽力保证本书内容的质量并避免错误的发生。如果你发现了本书的任何错误——可能是在文字描述上或代码中——我们将非常感谢你能联系我们。这样做可以为其他读者提供帮助并有助于我们提高本书后续的内容质量。如果你发现了任何勘误,请通过访问http://www.packtpub.com/support来联系我们,选择你的书并单击let us know链接,然后勘误会被上传到我们的网站,或是添加到该书名下的勘误列表。任何已有的勘误都可以通过在http://www.packtpub.com/support选择书名来进行查看。
对各种媒体而言,互联网上受版权保护的各种材料都长期面临非法复制的问题。Packt非常重视对版权和许可的保护。如果你在网络上发现了任何对我们的内容进行非法复制的情形,请立即为我们提供网址或网站名称,这样我们可以采取相应措施。
你也可以通过copyright@packtpub.com联系我们,提供盗版材料的链接。
我们感谢你能协助保护作者版权并帮助我们为你提供更有价值的内容。
如果你对本书任何一方面存在疑问,可以通过questions@packtpub.com和我们联系,我们会尽力提供答复。
Mike Driscoll从2006年开始使用Python语言,他很喜欢在自己的博客中分享有关Python的使用技巧(博客地址:http://www.blog.pythonlibrary.org/)。他也是DZone出版的Core Python refcard一书的作者之一,同时也在Packt出版社出版过多本个人著作,例如Python 3 Object Oriented Programming、Python 2.6 Graphics Cookbook和Tkinter GUI Application Development Hotshot。Mike最近在写Python 101这本书。
我要感谢我的妻子Evangeline对我长期以来的支持,感谢我的朋友和家庭成员为我所做的一切。
Róman Joost从1997年就开始从事开源项目的开发。作为GIMP用户文档的项目经理,他已经为GIMP和Python/Zope的开源项目做了8年的贡献。目前Róman在澳大利亚布里斯班的红帽公司工作。
SakisKasampalis来自新西兰,目前在基于位置服务的B2B提供商担任软件开发工程师。他不赞成对编程语言和开发工具持过度追捧的态度。他的原则是把正确的技术应用在适合的问题上。Python是他最喜爱的工具之一,他很看重Python的高效。在FOSS的项目工作期间,Kasampalis维护GitHub上的一个与Python设计模式相关的项目,相关资料可以从https://github.com/faif/python-patterns下载。同时,他也是Packt出版的Learning Python Design Patterns一书的审阅者。
Albert LukaszewskiPh.D目前是苏格兰南部的Lukaszewski咨询服务中心的首席顾问。他已经有30多年的编程经验,现在是系统设计与实现方面的顾问。他之前还在爱可信欧洲有限公司担任过首席工程师。他的大部分经验都与文本处理、数据库系统和自然语言处理(Natural Language Processing,NLP)相关。同时,他还写过MySQL for Python一书,该书由Packt出版。除此之外,他之前还曾为纽约时报子公司About.com写过Python专栏。
Hugo Solis是哥斯达黎加大学物理系的教授助理。他目前主要研究计算宇宙学、复杂性和氢对材料特性的影响。他在使用C/C++和Python进行科研和可视化方面有着丰富的编程经验。他是自由软件基金会的成员之一,也做过一些开源项目。目前他主要负责管理IFT,这是哥斯达黎加的一个非营利的科研机构,致力于物理学科的实践(http://iftucr.org)。
我要向我亲爱的母亲Katty Sanchez表示感谢,感谢她的支持以及诸多不错的创意。
为了使本书接下来的内容更清晰,我们先来看一些关心的问题。其中一项是21点游戏。我们将重点关注21点游戏的模拟,但并不赞成赌博。
然而,对于面向对象编程来说,模拟是最早的问题之一。这也是能够体现出面向对象编程优雅的一个情形。有关更多信息,可参见http://en.wikipedia.org/wiki/Simula,以及Rob Pooley写的An Introduction to Programming。
本章会介绍一些工具的背景,它们是编写出完整的Python程序和包的基础。在接下来的章中会使用它们。
我们会使用timeit模块将面向对象设计进行对比,找出性能更好的那个。在很多有关如何更好地写出适用于问题模型代码的主观考虑中,使用客观事实来进行说明是非常重要的。
我们将介绍如何在面向对象中使用unittest
和doctest
模块,它们是在开发过程中核对实际工作的基本工具。
一个良好的面向对象设计应当是清晰的并且可读性很强。为了确保良好的可读性,编写Python风格的文档是必要的。Docstrings在模块、类和方法中都很重要。我们会在这里简单概括RST标记并会在第18章“质量和文档”中详细介绍。
此外,我们还要解决集成开发环境(Integrated Development Environment,IDE)的问题。常见的问题是Python开发最好的IDE。
最后,我们会介绍Python中特殊基本方法的概念。关于特殊方法,在前7章都有介绍。在这里,我们会介绍一些有助于理解第1部分“用特殊方法实现Python风格的类”的背景知识。
在讨论Python面向对象编程过程中,将尽量避免一些题外话。我们会假设你已经读了Python 3 Object Oriented Programming这本书。我们不会重复在其他地方已经讲得很清楚的内容。在本书中,会完全关注Python 3的内容。
我们会引用很多常见的面向对象设计模式,也不会重复在Learning Python Design Patterns书中出现的内容。
如果你还不熟悉21点游戏,以下是大致的介绍。
游戏的最终目标是,从庄家手中拿到牌,将手中的牌组成和为在庄家点总数与21之间的数字。
在纸牌中数字牌(2到10)包含了牌的点数值。而非数字牌(J、Q、K)等同于10点。而A等于11点或1点。当把A当作11点使用时,手中牌的值被称为软手。当将A当作1点使用时,手中牌的值称为硬手。
如果手中牌中包含了A和7,就可以当作8点硬手或18点软手。
有4种两张牌的组合可以构成21点。它们都称为21点,尽管其中一种组合包含了J。
21点游戏在不同的场合会有所不同,但主要流程类似,包含如下几点。
手牌的最终评判如下。
现在暂时不关心最终的收益。对不同玩法和下注策略的模拟过程来说,总收益关系不大。
对于21点游戏来说,玩家必须使用以下两种策略。
这两种策略是介绍策略模式不错的例子。
我们将使用游戏中的元素,例如玩家手中的牌作为对象模型的例子。然而,不会对整个过程进行模拟。我们会重点关注游戏中的元素,因为它们会有细微的差别但不是特别复杂。
使用一个简单的容器:存放手中的牌对象,可以包含0个或多个。
介绍Card的子类:NumberCard
,FaceCard
和Ace
。
介绍几种不同的方式来定义这种简单的类层次结构。由于层次结构很小(并且简单),可以简单对几种不同的实现方式进行尝试。
介绍几种实现玩家手中牌的方式。这只是一个简单的纸牌集合,包含了一些额外的功能。
从全局的视角来看玩家对象,玩家会有几手牌和下注策略以及21点游戏策略。这是一个复杂的组合对象。
我们也会对洗牌和发牌进行快速介绍。
我们会使用timeit
模块来将不同面向对象设计和Python结构进行对比,timeit
模块包含了很多函数。重点关注的是timeit
,这个函数会为一些语句创建一个Timer对象,也会包含一些预备环境的安装代码,然后调用Timer
的timeit()
方法来执行一次安装过程并重复执行目标语句。返回值为运行语句所需的时间。
默认计数为100000次。这提供了一个有意义的平均时间值,来自其他计算机上OS级别活动的统计。对于复杂的或长时间运行的语句,需要谨慎使用小计数值。
以下是与timeit简单交互的示例代码:
>>> timeit.timeit( "obj.method()", """
... class SomeClass:
... def method(self):
... pass
... obj= SomeClass()
""")
0.1980541350058047
下载示例代码 你可以通过自己的帐号下载所有从Packt出版社所购买的书籍中的示例代码: http://www.packtpub.com 。如果你是从其他地方购买的,可以访问http://www.packtpub.com/support 并注册,我们会通过邮件形式发送给你。 |
obj.method()
语句以字符串的形式提供给timeit()
,安装为类定义并且也由字符串的形式提供。语句中所需要的任何东西都必须在安装中提供,它包括所有的导入和所有的变量定义以及对象创建。
可能会需要多尝试几次来完成安装过程。当使用交互式Python时,经常会由于命令行窗口的翻屏导致无法追踪全局变量和导入信息。有一个例子是,10000次空方法的调用,花了0.198秒。
以下是另一个使用timeit的例子:
>>> timeit.timeit( "f()","""
... def f():
... pass
... """ )
0.13721893899491988
这个例子说明了,空函数的调用会比空方法的调用略快一些,在这个例子中差不多为44%。
在一些情况下,OS的开销可以作为性能的测量组件,它们通常源自难以控制的因素。在这个模块中可以使用repeat()函数来替代timeit()函数。它会收集基本定时的多个样本,对OS在性能上的影响做进一步分析。
对于我们而言,timeit()函数会提供所有反馈信息,我们可用于在客观上对不同面向对象涉及的要素进行评估。
单元测试当然是基本的。如果没有用于展示某个功能的单元测试,那么这个功能就不是真的存在。换句话说,对于一个功能来说,直到有测试可以说明它已经完成才算是完成。
我们只会对测试进行少量介绍。如果对每个面向对象设计功能的测试都进行深入介绍,那么这本书的厚度应该是现在的两倍。在忽略测试内容的细节上会存在一个误区,好的单元测试似乎只是可选的。当然不是,它们是必需的。
单元测试是必需的 如果有疑问,可以先设计测试用例,再对代码进行修改,满足测试用例。 |
Python提供了两种内置的测试框架。大部分应用和库会同时使用两者。对于所有测试来说,普遍的一种封装为unittest
模块。另外,在许多公共API docstrings中可以找到一些例子,都使用了doctest
模块。而且,在unittest
中可以包含doctest
中的一些模块。
好一点的做法是,每个类和函数都至少有一个单元测试。更重要的是,可见的类、函数和模块也要包含doctest
。还有更好的做法:100%的代码覆盖,100%的逻辑分支覆盖等。
实际上,一些类不需要测试。例如由namedtuple()创建的类不需要单元测试,除非首先去怀疑namedtuple()的实现。如果不相信Python的实现,就无法基于它来写程序。
一般地,我们会先设计测试用例再编写可以通过测试用例的代码。测试用例会凸显出代码中API的形态。本书会介绍几种写代码的方式,它们的接口是相同的,这点很重要。一旦我们定义了接口,会有几种不同的实现方式。一组测试应该能够适应几种不同面向对象的设计。
一种常见的方式是使用unittest
工具为项目创建至少有以下3种平行的目录。
myproject
:这个目录会需要安装在lib/site-packages
中,作为应用最终的包。它会有一个__init__.py
包,而且会放在每个模块中。 -test
:这个目录包含测试脚本。对于一些情形,这些脚本在模块中是平行存在的。在一些情况下,脚本可能会很大并且比模块自身更复杂。doc
:这个目录中会包含其他文档。我们会在下一节以及第18章“质量和文档”中对它进行介绍。在一些情况下,你会希望在多个类上运行同样的测试组件,这样就能够确保每个类是工作的。但在根本不工作的类上使用timeit进行比较是没有意义的。
作为面向对象设计的一部分,通常会创建一个类似本节代码中所演示的技术探究模块,我们会把它分为3个部分。首先,是以下这个全局的抽象类。
import types
import unittest
class TestAccess( unittest.TestCase ):
def test_should_add_and_get_attribute( self ):
self.object.new_attribute= True
self.assertTrue( self.object.new_attribute )
def test_should_fail_on_missing( self ):
self.assertRaises( AttributeError, lambda: self.object.
undefined )
抽象类TestCase
的子类中定义了一些希望类可以通过的测试。实际被测试的对象被忽略了。它通过self.object
被引用,但是没有提供定义,使得TestCase
子类保持抽象。每个具体类都会需要setUp()
方法。
以下是3个具体的TestAccess
子类,会包含以下3种不同对象的测试。
class SomeClass:
pass
class Test_EmptyClass( TestAccess ):
def setUp( self ):
self.object= SomeClass()
class Test_Namespace( TestAccess ):
def setUp( self ):
self.object= types.SimpleNamespace()
class Test_Object( TestAccess ):
def setUp( self ):
self.object= object()
TestAccess
类的每个子类都提供了所需要的setUp()
方法。每个方法创建了一种不同的被测试对象。第1个是空类的实例。第2个是types.SimpleNamespace
的实例。第3个是object
的实例。
为了运行这些测试,需要创建一个组件,来阻止我们运行TestAccess抽象类的测试。
以下是探究的其余部分。
def suite():
s= unittest.TestSuite()
s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_
EmptyClass) )
s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_
Namespace) )
s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_
Object) )
return s
if __name__ == "__main__":
t= unittest.TextTestRunner()
t.run( suite() )
现在我们得到了具体的证据,object
类的使用方式与其他类是不同的。进一步说,我们有了一个可以用于演示其他可行(或不可行)设计的测试。例如,用于演示types. SimpleNamespace
作为空类行为的测试。
我们跳过了很多单元测试用例的细节,会在第15章“可测试性的设计”中进行详细介绍。
所有的Python代码都应该在模块、类和方法级别包含docstrings。不是每个方法都需要docstring,有一些方法名已经很好了,不需要进一步说明。而大多数情况下,文档的说明是基本的。
Python文档通常使用ReStructured Text(RST)标记来写。
然而,在本书的示例代码中,为了限制本书内容在合理的范围内,没有使用docstrings。这样的缺点是,docstrings看起来是可选的,可它们是必需的。
再次强调,docstrings是必需的。
docstrings在Python中通过以下3种方式使用。
help()
函数用于显示docstrings。doctest
工具可以在docstrings中查找示例并把它们当作测试用例运行。由于RST相对简单,编写好的docstrings相对非常简单。我们会在第18章“质量和文档”中对文档以及预计标记进行详细介绍。现在通过一个例子来看一下docstring的形式。
def factorial( n ):
"""Compute n! recursively.
:param n: an integer >= 0
:returns: n!
Because of Python's stack limitation, this won't
compute a value larger than about 1000!.
>>> factorial(5)
120
"""
if n == 0: return 1
return n*factorial(n-1)
以上代码展示了RST标记的参数和返回值,还包括了关于限制的一段说明。所包括的doctest输出可用于验证使用doctest工具完成的实现。有很多标记功能可用于提供更多的结构和语义方面的信息。
关于Python开发的IDE常见问题是最好的IDE是什么。简单的回答是IDE的选择根本不重要,支持Python的开发环境实在太多了。
本书的所有实例都通过Python的>>>提示来演示交互的过程。运行能够交互的例子是非常有意义的。精心编写的Python代码应该很简单,并能够从命令行运行。
我们应该能够在>>>提示中展示一个设计。 |
从>>>提示来运行代码是对Python设计复杂度的一个重要的质量测试。如果类或函数过于复杂,那么就没有办法从>>>提示运行。对于一些复杂的类,应该提供模仿对象来模拟简单的交互过程。
Python有多层的实现,但我们只关心其中两层。
从表面上看,我们有Python的源代码。源代码是传统面向对象与过程式函数调用的混合体。面向对象符号的后缀中通常包括object.method()
或object.attribute
这样的结构。而前缀中包括了function(object)
的调用,是典型的过程式设计。此外还包含了插入符,例如object+other
。另外还有其他语句,例如for
和调用对象方法的with
语句。
function(object)
前缀的出现会导致一些程序员产生疑问,是否进行纯面向对象的Python编程。认为严格的遵守面向对象(object.method())
的设计方式是有必要或有帮助的,这种说法是不够明确的。Python混合使用了前缀和后缀的编程方式,前缀符号代表了特殊方法的后缀符号。前缀、中缀和后缀符号的选择要基于表达力和优雅程度。良好Python代码的目标之一是,它看起来应该像英文。在底层,语法变化是由Python特殊方法实现的。
在Python中的任何事物都是对象。这点与Java或C++不同,它们会有“原始”类型来避免对象范型。每个Python对象都提供了一个特殊方法的数组,其中包含了语言最上层功能的实现细节。例如,可以在应用程序中写str(x)。这个前缀符号在底层的实现为x.__str__()
。
类似a+b这样的结构会被实现为a.__add__(b)
或b.__radd__(a)
,取决于对象a和b所属的类定义中所提供的类型兼容性规则。
需要强调的是,在外部语法与特殊方法内部实现之间的映射不只是把function(x)
重写为x.__function__()
。在许多语言功能中,包含了一些特殊方法支持这项功能。一些特殊方法包含了从基类、object
所继承的默认实现,另一些特殊方法则没有默认实现而会直接抛出异常。
第1部分“用特殊方法实现Python风格的类”将会介绍这些特殊方法并会演示如何实现这些特殊方法,以使得我们的类定义能够与Python无缝结合。
我们介绍了示例的问题域:21点游戏。选择这个例子是因为它包含了一定的算法复杂度但又不是过于复杂或者难懂。另外也介绍了在本书中会用到的3个重要模块。
timeit
模块,我们会用于对比不同实现的性能。unittest
和doctest
模块,我们会用于确保软件能够正确运行。书中也介绍了几种向Python程序中添加文档的方式。我们会在模块、类和函数中使用docstrings。为了节省空间,不是每个例子都会展示docstrings,但它们都是最基本的。
集成开发环境(Integrated Development Environment,IDE)的使用不是基本的,任何有效的IDE或文本编辑器对于高级Python开发都应该是可以选择的。
在后续的8章中,我们将对特殊方法名进行分类介绍,内容主要包括如何能够创建出与内置模块无缝集成的Python程序。
在第1章中,我们会重点关注__init__()
方法以及使用它的不同方式。__init__()
方法很重要,因为初始化是对象生命周期的第1个大步骤;每个对象必须正确地初始化才能很好地工作。更重要的是,__init__()
参数值的形式有很多种。我们会介绍几种不同设计__init__()
的方式。
|
Python风格的类 通过重写特殊方法来完成对Python内部机制的调用,在Python中是很普遍的。例如len()
函数就可以重写一个类的__len__()
方法。
这意味着对于像(len(x))
这样的通用公共接口,任何类(例如,声明一个类叫tidy)都可以实现它。任何类都可以重写一个像__len()__
这样的特殊方法,这样一种机制构成了Python多态机制的一部分;任何实现了__len()__
函数的类都会响应通用公共接口(len(x))
中的len()
函数。
每当定义一个类,可以(而且应该)提供这些特殊方法的实现来与Python语言更好地结合。本书的第1部分“用特殊方法实现Python风格的类”是对传统面向对象设计的一种延伸,可以使创建的Python类更具Python风格。任何一个类都应当与Python语言其余的任何原生部分很好地结合。这样一来,既可以重用很多其他语言现有的功能和标准库,而且编写的包和模块也将更容易维护和扩展。
在某种程度上,创建的类都可以作为Python扩展的形式来实现。开发者都希望自己的类更接近Python语言的原生类。这样一来,在语言之间、标准库之间以及应用程序之间的代码区别就能够最小化。
为了实现更好的可扩展性,Python语言提供了大量的特殊方法,它们大致分为以下几类。
object.attribute
,既可以用来赋值,也可以在del
语句中执行删除操作。len ()
函数。(也是应用于参数。)sequence[index]
、mapping[key]
和some_set | another_set
。with
语句来实现上下文的管理。在Python 3 Object Oriented Programming一书中已经介绍了这些特殊方法中的一部分,以下我们将重新回顾这些主题并对其他属于基本范畴的特殊方法进行深入介绍。
尽管是基础的范畴,仍可以针对其他比较深入的主题进行讨论。这里将会以基础的几个特殊方法作为开始,后续会讨论一些高级的特殊方法。
__init__()
函数为对象的初始化操作提供了很大的自由度,对于不可变(每次操作都会产生一个新实例)的对象而言,声明和定义是非常重要的。在第1章中,我们会讨论一些关于这个函数设计的方案。
__init__()
方法__init__()
方法的重要性体现在两点。首先,初始化既是对象生命周期的开始,也是非常重要的一个步骤,每个对象都必须正确地执行了初始化才能够正常地工作。其次,__init()__
方法的参数可以多种形式来完成赋值。
因为__init()__
方法传参方式的多样化,意味着对象的初始化过程也会有多种。关于这一点我们将使用一些有代表性的例子对此进行详细说明。
在深入讨论__init__()
函数之前,需要看一下Python语言的类层次结构。简单地说,所有的类都可以继承object
类,在自定义类中可以提供比较操作的默认实现。
本章会演示简单对象初始化的不同形式(例如,打牌)。随后将深入探讨复杂对象的初始化过程,涉及集合以及使用策略和状态模式实现的玩家类。
每个Python类的定义都会隐式继承自object
类,它的定义非常简单,几乎什么行为都不包括。我们可以创建一个object
实例,但很多事情无法完成,因为很多特殊方法的调用程序都会抛出异常。
对于任何自定义类,都会隐式继承object
。以下是一个类定义的示例(隐式继承了object
类)。
class X:
pass
下面是对自定义类进行交互的代码。
>>> X.__class__
<class 'type'>
>>> X.__class__.__base__
<class 'object'>
可以看到类定义就是对type
类的一个对象的类型声明,基类为object
。
相应地,派生自object
类中的对象方法也将继承各自相应的默认实现。在某些情况下,基类中一些特殊方法的默认行为也正是我们想要的。对于一些特殊情况,就需要重写这些方法。
__init__()
方法对象的生命周期主要包括了创建、初始化和销毁。后面章节会详细讨论对象的创建和销毁,本章专注于对象的初始化。
object作为所有类的基类,已经为__init__()
方法提供了默认实现,一般情况下不需要重写这个函数。如果没有对它进行重写,那么在创建对象时将不会产生其他变量的实例。在某些情况下,这种默认行为是可以接受的。
对于继承自object
的子类,总可以对它的属性进行扩展。例如,对于下面这个类,实例化就不对函数(area
)所需要的变量(width
和length
)进行初始化。
class Rectangle:
def area( self ):
return self.length * self.width
Rectangle
类的area
函数在返回值时使用了两个属性,可并没有在任何地方对其赋值。在Python中,这种看似奇怪的调用尚未赋值属性的操作却是合法的。
下面这段代码演示如何使用刚定义的Rectangle
类。
>>> r= Rectangle()
>>> r.length, r.width = 13, 8
>>>r.area()
104
虽然这种延迟赋值的实现方式在Python中是合法的,但是却给调用者带来了潜在的困惑,因此要尽量避免这样的用法。
然而,这样的设计看似又提供了灵活性,意味着在__init__()
方法被调用时不必为所有的属性赋值。这看似是不错的选择,一个可选属性即可以看作是某子类中的成员,且无须对这个子类进行显式地定义就可以完成对原生机制的扩展。然而这种多态机制不但给程序带来了隐藏的不确定性,也会相应产生很多令人费解的if
语句。
因此,延迟初始化属性的设计在某种情形下可能会有用,可是这样也可能会导致非常糟糕的设计。
在Zen of python poem一书中曾提出过这样的建议:
“显式而非隐式”。
对于每个__init__()
方法,都应当显式地指定要初始化的变量。
糟糕的多态 在灵活性与糟糕之间有一个临界。 一旦发觉书写了这样的代码,我们就已经丧失了灵活性并开始了糟糕的设计。 if 'x' in self.__dict__: 或: try: self.x except AttributeError: 这时就要考虑添加一个公共函数或属性来重构这个API,相比于添加if语句,重构将是更好的选择。 |
通过实现__init()__
方法来初始化一个对象。每当创建一个对象,Python会先创建一个空对象,然后调用该对象的__init()__
函数。这个方法提供了对象内部变量以及其他一些一次性过程的初始化操作。
以下是关于一个Card
类层次结构定义的一些例子。这里定义了一个基类和3个子类来描述Card
类的基本信息。有两个变量是参数直接赋值的,另外两个参数是通过初始化方法计算来完成初始化的。
class Card:
def __init__( self, rank, suit ):
self.suit= suit
self.rank= rank
self.hard, self.soft = self._points()
class NumberCard( Card ):
def _points( self ):
return int(self.rank), int(self.rank)
class AceCard( Card ):
def _points( self ):
return 1, 11
class FaceCard( Card ):
def _points( self ):
return 10, 10
在以上代码段中,__init()__
把公共初始化方法引入到了基类Card
中,这样3个子类NumberCard
、AceCard
和FaceCard
都能够共享公共的初始化逻辑。
这是一个常见的多态设计,每个子类为_points()
方法提供特有的实现。所有的子类有相同的方法名和属性。这3个子类在使用时可以通过互换对象来更换实现方式。
如果只是简单地使用字母来定义花色,就可以使用如下的代码段来创建Card
对象。
cards = [ AceCard('A', '♠'), NumberCard('2','♠'), NumberCard('3','♠'), ]
这里枚举了Card
集合中的几个Card
对象,把牌面值(rank)和花色(suit)作为参数传入来实例化。从长远来看,需要一个更智能的工厂函数来创建Card
对象,因为枚举所有52张牌非常麻烦而且容易出错。在介绍工厂函数前,先看一些其他的问题。
__init()__
方法创建常量清单我们可以为所有卡片的花色单独创建一个类。可在21点应用中,花色不是很重要,用一个字母来代替就可以。
这里使用花色的初始化作为创建常量对象的一个实例。很多情况下,应用会包括一个常量集合。静态常量也正构成了策略(Strategy)或状态(State)模式的一部分。
有些情况下,常量会在应用或配置文件的初始化阶段被创建。或者创建变量的行为是基于命令行参数的。我们会在第16章“使用命令行”中介绍应用初始化和启动的详细设计过程。
Python中并没有提供简单而直接的方式来定义一个不可变对象。我们会在第3章“属性访问、特性和修饰符”中介绍如何创建可靠的不可变对象。这个例子中,把花色这个属性定义为不可变是有意义的。
如下代码定义了一个花色类,可以用来创建4个花色常量。
class Suit:
def __init__( self, name, symbol ):
self.name= name
self.symbol= symbol
如下代码是对这个类的调用。
Club, Diamond, Heart, Spade = Suit('Club','♣'), Suit('Diamond','♦'),
Suit('Heart','♥'), Suit('Spade','♠')
现在就可以使用如下代码创建Card对象了。
cards = [ AceCard('A', Spade), NumberCard('2', Spade), NumberCard('3', Spade), ]
对于以上的这个小例子来说,这样的方式相比于简单地使用一个字母来代替花色的实现方式并没有太大的优势。可在更复杂的情况下,可能会需要创建一组策略或状态模式对象的集合。如果把创建好的花色对象做缓存,构成一个常量池,使得在调用时对象可被重用,那么性能将得到显著的提升。
我们不得不承认在Python中这些对象只是在概念上是常量,它们仍然是可变的。使用额外的代码实现使得这些对象成为完全不可变的可能会更好。
无关紧要的不可变性 不可变性可能显得很有诱惑力。有时一些“恶意程序员”会修改应用程序中的常量。从设计的角度来看,这是愚蠢的,即使不可变变量也无法阻止这种恶意行为。没有任何简单的方法能够阻止这种恶意行为,程序员对代码进行恶意修改就像他们可以修改一个常量那样简单。 不再纠结于如何把类定义为不可变通常是更好的选择。在第3章“属性访问、特性和修饰符”中,我们会介绍不可变性的几种实现方法来为有bug的程序提供适当的诊断信息。 |
__init()__
我们可以使用工厂函数来完成所有Card
对象的创建,这比枚举52张牌的方式好很多。在Python中,实现工厂有两种途径。
在Python里,类定义不是必需的。仅当特别复杂的情形,工厂类才是不错的选择。Python的优势之一是,对于只需要简单地定义一个函数就能做到的事情没必要去定义类层次结构。
尽管本书介绍的是面向对象编程,但函数式编程在Python的世界中也是常见的、惯用的。 |
如果需要,我们总可以将函数重写为合适的可调用对象。进行工厂模式设计时,也可以将可调用对象进一步重构为工厂类的层次结构。我们将在第5章“可调用对象和上下文的使用”中详细介绍可调用对象。
从大体上来看,类定义的优势是:可以通过继承来使得代码可以被更好地重用。工厂类封装了类本身的层次结构以及对象构建的复杂过程。对于已有的工厂类,可以通过添加子类的方式来完成扩展,这样就获得了工厂类的多态设计,不同的工厂类名有相同的方法签名并可以在调用时通过替换对象来改变具体实现。
这种类级别的多态机制对于类似Java和C++这样的编译型语言来说是非常有用的,可以在编译器在生成目标代码时决定类和方法的实现细节。
如果可替代的工厂类并没有重用任何代码,那么类层次结构在Python中并没有多大作用,完全可以使用函数来替代。
以下是用来生成Card
子类对象的一个工厂函数的例子。
def card( rank, suit ):
if rank == 1: return AceCard( 'A', suit )
elif 2 <= rank < 11: return NumberCard( str(rank), suit )
elif 11 <= rank < 14:
name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
return FaceCard( name, suit )
else:
raise Exception( "Rank out of range" )
这个函数通过传入牌面值rank
和花色值suit
来创建Card
对象。这样一来,创建对象的工作更简便了。我们已经把创造对象的过程封装在了单独的工厂函数内,外界无需了解对象层次结构以及多态的工作细节就可以通过调用工厂函数来创建对象。
如下代码演示了如何使用工厂函数来构造deck对象。
deck = [card(rank, suit)
for rank in range(1,14)
for suit in (Club, Diamond, Heart, Spade)]
这段代码枚举了所有牌面值和花色的牌,完成了52张牌对象的创建。
这里需要注意card()
函数里的if
语句。并没有使用一个catch-all else
语句做一些其他步骤,而只是单纯地抛出了一个异常。像这样的catch-all else
语句的使用方式是有争议的。
一方面,else
语句不能不做任何事情,因为这将隐藏微小的设计错误。另一方面,一些else
语句的意图已经很明显了。
因此,避免模糊的else
语句是非常重要的。
关于这一点,可以参照以下工厂函数的定义。
def card2( rank, suit ):
if rank == 1: return AceCard( 'A', suit )
elif 2 <= rank < 11: return NumberCard( str(rank), suit )
else:
name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
return FaceCard( name, suit )
创建纸牌对象可以通过如下代码实现。
deck2 = [card2(rank, suit) for rank in range(13) for suit in (Club,
Diamond, Heart, Spade)]
这是最好的方式吗?如果if
条件更复杂些呢?
一些程序员可以很快理解这样的if
语句,而另一些则会纠结于是否要对if
语句的逻辑做进一步划分。
作为高级的Python程序员,我们不应该把else
语句的意图留给读者去推断,条件语句的意图应当是非常直接的。
什么时候使用catch-all语句 很少,仅当条件非常明确时才使用。如果条件不够明确,使用else语句将抛出异常。因此要避免使用模糊的else语句。 |
工厂方法card()
中包括了两个很常见的结构。
if-elif
序列。为了简单化,重构将是更好的选择。
我们总可以使用elif
条件语句代替映射。(是的,总可以。反过来却不行;把elif
条件转换为映射有时是有风险的。)
以下是没有使用映射Card
工厂类的实现。
def card3( rank, suit ):
if rank == 1: return AceCard( 'A', suit )
elif 2 <= rank < 11: return NumberCard( str(rank), suit )
elif rank == 11:
return FaceCard( 'J', suit )
elif rank == 12:
return FaceCard( 'Q', suit )
elif rank == 13:
return FaceCard( 'K', suit )
else:
raise Exception( "Rank out of range" )
这里重写了card()
工厂方法,将映射转换为了elif
语句。比起前一个版本,这个函数在实现上获得了更好的一致性。
在一些情形下,可以使用映射而非这样的一个elif
条件语句链。如果认为使用一个elif
条件语句链是表达逻辑的唯一明智的方式,那么很容易会发现,它看起来很复杂。对于简单的情形,做同样的事情采用映射完成的代码可以更好地工作,而且代码的可读性也更强。
由于类是第1级别的对象,从rank
参数映射到对象是很容易的事情。
这个Card
工厂类就是使用映射实现的版本。
def card4( rank, suit ):
class_= {1: AceCard, 11: FaceCard, 12: FaceCard,
13: FaceCard}.get(rank, NumberCard)
return class_( rank, suit )
我们把rank
映射为对象,然后又把rank
值和suit
值作为参数传入Card
构造函数来创建Card
实例。
也可以使用一个defaultdict
类,然而比起简单的静态映射其实并没有简化多少。下例就是它的实现。
defaultdict( lambda: NumberCard, {1: AceCard, 11: FaceCard, 12:
FaceCard, 13: FaceCard} )
defaultdict
类的默认构造函数必须是无参的。我们使用了一个lambda
构造函数作为常量的封装函数。这个函数有个很明显的缺陷,缺少从1到A和13到K的映射。当试图添加这段代码逻辑时,就遇到了个问题。
我们需要修改映射逻辑,除了提供Card
子类,还需要提供rank
对象的字符串结果。如何实现这两部分的映射?有4种常见的方案。
partial()
函数。partial()
函数是fun``ctools
模块的一个功能。__init()__
函数来完成这个方案。对于每个方案我们会通过具体示例逐一演示。
以下是此方案代码的基本实现。
class_= {1: AceCard, 11: FaceCard, 12: FaceCard, 13: FaceCard
}.get(rank, NumberCard)
rank_str= {1:'A', 11:'J', 12:'Q', 13:'K'}.get(rank,str(rank))
return class_( rank_str, suit)
这样是不值得的。这种实现方式带来了映射键1、11、12和13的逻辑重复。重复是糟糕的,因为软件更新后通常会带来对并行结构多余的维护成本。
不要使用并行结构 并行结构应该被元组或一些更好的组合所代替。 |
以下代码演示了如何映射到二元组的基本实现。
class_, rank_str= {
1: (AceCard,'A'),
11: (FaceCard,'J'),
12: (FaceCard,'Q'),
13: (FaceCard,'K'),
}.get(rank, (NumberCard, str(rank)))
return class_( rank_str, suit )
这个方案看起来还不错。并没有太多代码来完成特殊情形的处理。接下来我们会看到当需要修改Card
类层次结构时:添加一个Card
子类时,如何来修改和扩展。
从rank
值映射为类对象是很少见的,而且两个参数中只有一个用于对象的初始化。从rank
映射到一个相对简单的类或函数对象,而不必提供目的不明确的参数,这才是明智的选择。
除了映射到二元组函数和只提供一个参数来实例化的方案外,我们还可以创建partial()
函数。这个函数可以用来实现可选参数。我们会从functools
库中使用partial()
函数创建一个带有rank
参数的部分类。
以下演示了如何建立从rank
到partial()
函数的映射来完成对象的初始化。
from functools import partial
part_class= {
1: partial(AceCard,'A'),
11: partial(FaceCard,'J'),
12: partial(FaceCard,'Q'),
13: partial(FaceCard,'K'),
}.get(rank, partial(NumberCard, str(rank)))
return part_class( suit )
通过调用partial()
函数然后赋值给part``_``class
,完成了与rank
对象的关联。可以使用同样的方式创建suit
对象,并完成最终Card
对象的创建。partial()
函数的使用在函数式编程中是很常见的,当使用的是函数而非对象方法的时候就可以考虑使用。
大致上,partial()
函数在面向对象编程中不是很常用。我们可以简单地提供构造函数的不同版本来做同样的事情。partial()
函数和构造对象时的流畅接口很类似。
有时我们定义在类中的方法必须按特定的顺序来调用。这种按顺序调用的方法和创建partial()
函数的方式非常类似。
假如有这样的函数调用x.a().b()
。对于x(a,b)这个函数,放在partial()
函数的实现就可以是先调用x.a()
再调用b()
函数,这种方式可以理解为x(a)(b)。
这意味着Python在管理状态方面提供了两种选择。我们可以直接更新对象或者对具有状态的对象使用partial()
函数。由于两种方式是等价的,因而可以把partial()
函数重构为工厂对象创建的流畅接口。我们在流畅接口函数中设置可以反馈self
值的rank
对象,然后传入花色类从而创建Card
实例。
如下是Card
工厂流畅接口的定义,包含了两个函数,它们必须按顺序调用。
class CardFactory:
def rank( self, rank ):
self.class_, self.rank_str= {
1:(AceCard,'A'),
11:(FaceCard,'J'),
12:(FaceCard,'Q'),
13:(FaceCard,'K'),
}.get(rank, (NumberCard, str(rank)))
return self
def suit( self, suit ):
return self.class_( self.rank_str, suit)
先是使用rank()
函数更新了构造函数的状态,然后通过suit()
函数创造了最终的Card
对象。
这个工厂类可以以如下方式来使用。
card8 = CardFactory()
deck8 = [card8.rank(r+1).suit(s) for r in range(13) for s in (Club,
Diamond, Heart, Spade)]
我们先实例化一个工厂对象,然后再创建Card
实例。这种方式并没有利用__init()__
在Card
类层次结构中的作用,改变的是调用者创建对象的方式。
__init()__
方法正如介绍工厂函数那样,这里我们也先看一些Card
类的设计实例。我们可以考虑重构rank
数值转换的代码,并把这个功能加在Card
类上。这样就可以把初始化的工作分发到每个子类来完成。
这通常需要在基类中完成一些公共的初始化逻辑,子类中完成各自特殊的初始化逻辑。我们需要遵守不要重复自己(Don't Repeat Yourself,DRY)的原则来防止子类中的代码重复。
以下代码演示了如何把初始化职责分发到各自的子类中。
class Card:
pass
class NumberCard( Card ):
def __init__( self, rank, suit ):
self.suit= suit
self.rank= str(rank)
self.hard = self.soft = rank
class AceCard( Card ):
def __init__( self, rank, suit ):
self.suit= suit
self.rank= "A"
self.hard, self.soft = 1, 11
class FaceCard( Card ):
def __init__( self, rank, suit ):
self.suit= suit
self.rank= {11: 'J', 12: 'Q', 13: 'K' }[rank]
self.hard = self.soft = 1
上例代码是多态的实现,由于缺乏公共初始化函数,导致了一些不受欢迎的重复代码。以上代码的主要重复部分是对suit
的赋值。这部分代码放在基类中显然比较合适。我们可以在子类中显式调用基类的__init()__
方法。
以下代码演示了如何把__init()__
方法提到基类Card
中实现的过程,然后在子类中可以重用基类的实现。
class Card:
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
class NumberCard( Card ):
def __init__( self, rank, suit ):
super().__init__( str(rank), suit, rank, rank )
class AceCard( Card ):
def __init__( self, rank, suit ):
super().__init__( "A", suit, 1, 11 )
class FaceCard( Card ):
def __init__( self, rank, suit ):
super().__init__( {11: 'J', 12: 'Q', 13: 'K' }[rank], suit,
10, 10 )
我们在子类和基类中都提供了__init()__
方法的实现,这样会在一定程度上简化工厂函数的逻辑,如下面代码段所示。
def card10( rank, suit ):
if rank == 1: return AceCard( rank, suit )
elif 2 <= rank < 11: return NumberCard( rank, suit )
elif 11 <= rank < 14: return FaceCard( rank, suit )
else:
raise Exception( "Rank out of range" )
仅仅是简化工厂函数不应该是我们重构焦点的全部。我们还应该看到这次的重构导致__init()__
方法变得复杂了,做这样的权衡是正常的。
使用工厂函数封装复杂性 在 __init__() 方法和工厂函数之间存在一些权衡。通常直接调用比“程序员友好”的__init__() 函数并把复杂性分发给工厂函数更好。当需要封装复杂的构造函数逻辑时考虑使用工厂函数则更好。 |
一个组合对象也可以称作容器。我们会从一个简单的组合对象开始介绍:一副牌。这是一个基本的集合对象。我们的确可以简单地使用一个list
来代替一副牌(deck)对象。
在设计一个类之前,我们需要考虑这样的一个问题:简单地使用list
是合适的做法吗?
可以使用random.shuffle()
函数完成洗牌操作,使用deck.pop()
来完成发牌操作。
一些程序员可能会过早定义新类,正如像使用内置类一样,违反了一些面向对象的设计原则。比如像下面的这个设计。
d= [card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart,
Spade)]
random.shuffle(d)
hand= [ d.pop(), d.pop() ]
可如果业务逻辑这么简单的话,为什么要定义新类?
这里没有明确的答案。类定义的一个优势是:类给对象提供了简单的、不需要实现的接口。正如之前在对工厂的设计讨论时所看到的,对于Python来说,类并不是必需的。
在之前的例子中,有两个关于deck的使用实例而且类定义似乎并不能过于简化。这有个很大的好处是它隐藏了具体的实现。而由于细节过于细微因此暴露它们并不需要太高的维护成本。本章主要专注于__init__()
方法,因此接下来会讨论一些关于如何创建和初始化一个集合的设计。
设计集合类,通常有如下3种策略。
这3个方面是面向对象设计的核心。我们在设计一个类时,总需要谨慎考虑再做出选择。
以下是对内部集合进行封装的设计。
class Deck:
def __init__( self ):
self._cards = [card6(r+1,s) for r in range(13) for s in (Club,
Diamond, Heart, Spade)]
random.shuffle( self._cards )
def pop( self ):
return self._cards.pop()
我们已经定义了Deck
类,内部实际调用的是list
对象。Deck
类的pop()
方法只是对list
对象相应函数的调用。
我们可以使用以下代码来创建一个Hand
对象:
d= Deck()
hand= [ d.pop(), d.pop() ]
一般来说,外观模式或者封装类中的方法实现只是对底层对象相应函数的代理调用。有时候这样的代理未免显得有些多余,因为对于复杂的集合,我们需要代理大量的函数来更完整地封装这个底层对象。
类设计的另一个选择是扩展现有类。这样做的好处是不需要再重新实现已有的pop()
方法了,只需简单地继承即可。重用pop()
方法的好处是,无需编写太多代码就可以创建一个类。在这个例子中,扩展list
类引入了很多我们实际并不需要的函数。
以下代码演示了基于对内部集合类扩展的Deck
类的定义。
class Deck2( list ):
def __init__( self ):
super().__init__( card6(r+1,s) for r in range(13) for s in
(Club, Diamond, Heart, Spade) )
random.shuffle( self )
在一些情形下,在子类中需要显式调用基类的函数来完成适当的实现。关于这一点,在接下来的章节中会看到其他一些例子。
我们使用了基类中的__init__()
函数来初始化list
对象进而构造了一个对象集合。然后进行洗牌操作。pop()
函数只需继承自list
集合就可以很好地工作了,其他函数也一样。
在玩牌时,牌通常会从一个发牌机中取出,这个容器通常包含了混在一起的6副牌。这样就需要我们来创建一个自定义的Deck
类而不再只是简单地从list
对象继承。
进一步说,发牌机并未完全发牌,而是插入一个标记牌。由于有一张标记牌,有些牌就被有效地分开了。
以下是一个Deck
类的定义,包含了多副牌,每副牌有52张牌。
class Deck3(list):
def __init__(self, decks=1):
super().__init__()
for i in range(decks):
self.extend( card6(r+1,s) for r in range(13) for s in
(Club, Diamond, Heart, Spade) )
random.shuffle( self )
burn= random.randint(1,52)
for i in range(burn): self.pop()
这里我们使用了基类的__init__()
函数来创建一个空集合。然后调用self.extend()
函数来把多副牌加载到发牌机中。由于我们没有在子类重写super().extend()
函数,因为我们也可以直接调用基类中相应的实现。
我们也可以使用更底层的表达式生成器通过调用super().__init__()
函数来实现,如以下代码所示。
( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart,
Spade) for d in range(decks) )
这个类提供了一副牌Card
实例的集合,可以用来模拟21点中的发牌机的发牌过程。
当销牌时,有一个特殊的过程。在我们设计玩家的纸牌计数策略时,也要考虑到这个细节。
为了描述21点游戏中的发牌。以下代码定义了Hand
类,用来模拟打牌策略。
class Hand:
def __init__( self, dealer_card ):
self.dealer_card= dealer_card
self.cards= []
def hard_total(self ):
return sum(c.hard for c in self.cards)
def soft_total(self ):
return sum(c.soft for c in self.cards)
在本例中,定义了一个self.dealer_card
变量,值由__init__()
函数传入。可self.cards
变量不基于任何参数来赋值,只是创建了一个空集合。使用如下代码可以创建一个Hand
实例。
d = Deck()
h = Hand( d.pop() )
h.cards.append( d.pop() )
h.cards.append( d.pop() )
可是这段代码有个缺陷,需要用好几行代码来构造一个Hand
对象。不但给序列化Hand
对象带来了困难,而且再次创建对象又需要再重复以上过程。尽管再添加一个append()
函数暴露给外面调用,也仍然需要很多步骤来创建集合对象。
可能会考虑使用流畅接口,但那样并不能简化实际问题。它只是在创建Hand
对象的语法上做了一些改变。流畅接口依然会需要多个步骤来创建对象。在第2部分“持久化和序列化”中,我们需要一个接口完成类之间的调用,可以通过类中的一个函数完成,而这个函数最好是构造函数。在第9章“序列化和保存 ——JSON、YAML、Pickle、CSV和XML”中会详细深入介绍。
可以注意到hard_total
函数和soft_total
函数并没有完全符合21点的规则。在第2章“与Phthon无缝集成——基本特殊方法”中会对这个问题进行讨论。
__init__()
初始化方法应当返回一个完整的对象,这样是理想的情况。而这样也带来了一些复杂性,因为要创建的对象内部可能包含了集合,集合里面又包含了其他对象。如果可以一步完成对象创建的工作这样是最好的。
通常考虑使用一个流畅接口来完成逐个将对象添加到集合的操作,同时将集合对象作为构造函数的参数来完成初始化。
例如,如下代码段对类的实现。
class Hand2:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards = list(cards)
def hard_total(self ):
return sum(c.hard for c in self.cards)
def soft_total(self ):
return sum(c.soft for c in self.cards)
代码中的初始化函数中完成了所有变量实例的赋值操作。其他函数的实现都是从上一个Hand
类的版本中复制过来的。此处可以用两种方式来创建Hand2
对象。第1种是一次加载一张牌。
d = Deck()
P = Hand2( d.pop() )
p.cards.append( d.pop() )
p.cards.append( d.pop() )
第2种使用*cards
参数一次加载多张牌。
d = Deck()
h = Hand2( d.pop(), d.pop(), d.pop() )
第2种初始化方式给单元测试代码带来了便利,构造一个复合对象只需一步。更重要的是,一步构造复合对象也有利于后续要介绍的序列化。
__init__()
方法的无状态对象以下是一个不需要__init__()
方法的类定义。对于策略模式的对象来说这是常见的设计。一个策略对象以插件的形式复合在主对象上来完成一种算法或逻辑。它或许依赖主对象中的数据,策略对象自身并不携带任何数据。通常策略类会和享元设计模式一起使用:在策略对象中避免内部存储。所有需要的值都从策略对象的方法参数传入。策略对象自身是无状态的,可以把它看作是一系列函数的集合。
这里定义了一个类给Player
实例提供游戏模式的选择,以下这个策略包括了拿牌和下调投注。
class GameStrategy:
def insurance( self, hand ):
return False
def split( self, hand ):
return False
def double( self, hand ):
return False
def hit( self, hand ):
return sum(c.hard for c in hand.cards) <= 17
每个函数需要传入已有的Hand
对象。函数逻辑所需的数据基于现有的可用信息,意味着数据会来自庄家和玩家手中的牌。
我们可以创建一个单例的策略对象给多个玩家实例来调用。
dumb = GameStrategy()
我们也可以根据 21 点给玩家提供的不同玩法,考虑定义一系列像这样的策略对象。
正如前面所提到的,玩家有两种策略:下注和打牌。每个Player
实例会和模拟器进行很多交互。我们这里把这个模拟器命名为Table
类。
Table
类的职责需要配合Player
实例完成以下事件。
Hand
对象。在一些场合中,新分出去的牌是可以再分的。Hand
实例,玩家必须基于当前玩法决定叫牌、双倍还是停叫。基于以上需求,我们可以看出Table
类需要提供一些API函数来获取牌局、创建Hand
对象、分牌、提供单手和多手策略以及支付,这个对象的职责很多,用于追踪与Players
集合所有相关操作的状态。
以下是Table
类中投注和牌的逻辑处理的相关代码。
class Table:
def __init__( self ):
self.deck = Deck()
def place_bet( self, amount ):
print( "Bet", amount )
def get_hand( self ):
try:
self.hand= Hand2( d.pop(), d.pop(), d.pop() )
self.hole_card= d.pop()
except IndexError:
# Out of cards: need to shuffle.
self.deck= Deck()
return self.get_hand()
print( "Deal", self.hand )
return self.hand
def can_insure( self, hand ):
return hand.dealer_card.insure
Table
类会被Player类调用,从而接受牌局、创建Hand
对象,然后决定手中的牌是否为保险下注。此外,还需要提供一些可以被Player
类用来获取牌和支付的函数。
在get_hand()
函数中的异常处理部分,并没有准确的模拟玩牌时的真实场景。这可能会导致统计不正确。更好的模拟方式是,在牌用尽的情况下需要新建一副牌并洗牌,而不是抛出异常。
为了更适当地交互设计并模拟真实的游戏场景,Player
类需要一个下注策略。下注策略是一个状态对象,它决定了初始的下注级别,通常当每局游戏输赢之后可以再次选择不同的下注策略。
理想情况下,希望有多个下注策略对象。Python中有一个模块包含了很多装饰器,可以用来创建抽象基类。一种非正式的创建策略对象的方式是在基类函数中抛出异常,用以标识一些方法必须在子类中提供实现。
以下代码包含了一个抽象基类和一个子类,用来定义一种下注策略。
class BettingStrategy:
def bet( self ):
raise NotImplementedError( "No bet method" )
def record_win( self ):
pass
def record_loss( self ):
pass
class Flat(BettingStrategy):
def bet( self ):
return 1
基类中定义了带有默认返回值的方法。抽象基类中的bet()
方法抛出异常,子类必须给出 bet()
方法的实现。其他方法可以选择是否使用基类的默认实现。前面给出的游戏策略加上这个下注策略,可以模拟出Play
类中更复杂的__init__()
函数的使用场景。
我们可以使用abc
模块来丰富抽象基类的实现,如以下代码段所示。
import abc
class BettingStrategy2(metaclass=abc.ABCMeta):
@abstractmethod
def bet( self ):
return 1
def record_win( self ):
pass
def record_loss( self ):
pass
它有两个好处:首先,它阻止了对抽象基类BettingStrategy2
的实例化,其次任何没有提供bet()
方法实现的子类也是不能被实例化的。如果我们试图创建一个类的实例,而这个类并没有提供抽象方法的实现,程序就会抛出一个异常。
当然,如果基类的抽象方法提供了实现,那么就是合法的,而且可以通过super().bet()
来调用。
__init__()
方法有些对象的创建来自多个来源。例如,我们也许需要克隆一个对象作为备忘录模式的一部分,或者冻结一个对象以使它可以用来作为字典的键或放入哈希集合;这也是set
和fronezenset
类的实现方式。
有很多全局的设计模式使用了多种方式来创建对象。其中一个为多策略初始化,__init__()
函数的实现逻辑较为复杂,也会用到类层次结构中不同的(静态)构造函数。
它们是非常不同的实现方式,在接口的定义上就有根本区别。
避免克隆方法 在Python中,克隆方法是很少用到的,因为它会引入不必要的重复对象。使用克隆或许意味着没有正确地理解Python中面向对象的设计原则。 一个克隆方法为对象创建的细节做了不必要的隐藏,被克隆的源对象无法知道目标对象的结构。然而,如果源对象提供了可读性和封装都良好的接口,反过来(目标对象知道源对象的结构)就是可以接受的。 |
之前的例子可以被高效的克隆是因为它们非常简单,在下一章中会进行展开描述。然而,为了更详细地说明更多关于对象克隆的基本技巧,我们会讨论一下如何把可变的Hand
对象冻结成为不可变的Hand
对象。
以下代码演示了两种创建Hand
对象的例子。
class Hand3:
def __init__( self, *args, **kw ):
if len(args) == 1 and isinstance(args[0],Hand3):
# Clone an existing hand; often a bad idea
other= args[0]
self.dealer_card= other.dealer_card
self.cards= other.cards
else:
# Build a fresh, new hand.
dealer_card, *cards = args
self.dealer_card= dealer_card
self.cards= list(cards)
第1种方式,Hand3
实例从已有的Hand3
对象创建。第2种方式,Hand3
对象的创建基于Card
实例。
一个fronzenset
对象的创建可以基于已有的实例,或基于已存在集合对象。下一章会具体介绍创建不可变对象。基于已有的Hand,创建一个Hand对象,可用于创建一个Hand对象的备忘录模式,例如下面这段实现。
h = Hand( deck.pop(), deck.pop(), deck.pop() )
memento= Hand( h )
我们使用mem ento
变量来保存Hand
对象。可以用来比较当前对象和之前被处理的对象,我们也可以冻结它用于集合或映射。
为了将多策略应用于初始化,通常要被迫放弃显式命名的参数。这样的设计虽然获得了灵活性,却使得参数名不够透明,意图不够明显,需要针对不同的使用场景分别提供文档进行解释说明。
也可以扩展初始化的实现来分离Hand
对象。要分离的Hand
对象只需修改构造函数。以下代码段演示了如何分离一个Hand
对象。
class Hand4:
def __init__( self, *args, **kw ):
if len(args) == 1 and isinstance(args[0],Hand4):
# Clone an existing handl often a bad idea
other= args[0]
self.dealer_card= other.dealer_card
self.cards= other.cards
elif len(args) == 2 and isinstance(args[0],Hand4) and 'split'
in kw:
# Split an existing hand
other, card= args
self.dealer_card= other.dealer_card
self.cards= [other.cards[kw['split']], card]
elif len(args) == 3:
# Build a fresh, new hand.
dealer_card, *cards = args
self.dealer_card= dealer_card
self.cards= list(cards)
else:
raise TypeError( "Invalid constructor args={0!r}
kw={1!r}".format(args, kw) )
def __str__( self ):
return ", ".join( map(str, self.cards) )
这个设计需要传入更多的纸牌对象来创建合适的、分离的Hand对象。当我们从一个Hand4
对象中分离出另一个Hand4
对象时,使用split参数作为索引从原Hand4
对象中读取Card
对象。以下代码演示了我们怎样分离出一个Hand对象。
d = Deck()
h = Hand4( d.pop(), d.pop(), d.pop() )
s1 = Hand4( h, d.pop(), split=0 )
s2 = Hand4( h, d.pop(), split=1 )
我们初始化了一个Hand4
类的实例然后再分离出其他的Hand4
实例,命名为s1
和s2
,然后将Card
对象传入每个Hand对象。在21点的规则中,只有当手中两张牌大小相等的时候才可允许分牌。可以看到__init__()
函数的逻辑已经非常复杂,优势在于,它可以基于已有集合同时创建多个像fronzenset
这样的对象。然而也将需要更多的注释和文档来说明这些行为。
当我们有多种方式来创建一个对象时,有时使用静态函数好过使用复杂的__init__()
函数。
也可以考虑使用类函数作为初始化的另一种选择,然而将依赖的对象作为参数传入函数会更好。当冻结或分离一个Hand
对象时,我们或许希望创建两个新的静态函数来完成任务。使用静态函数作为代理构造函数在语法上略有差别,但是在代码的组织上却有明显的优势。
以下是Hand
类的实现,使用了静态函数来完成初始化,从已有的Hand
实例创建两个新实例。
class Hand5:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards = list(cards)
@staticmethod
def freeze( other ):
hand= Hand5( other.dealer_card, *other.cards )
return hand
@staticmethod
def split( other, card0, card1 ):
hand0= Hand5( other.dealer_card, other.cards[0], card0 )
hand1= Hand5( other.dealer_card, other.cards[1], card1 )
return hand0, hand1
def __str__( self ):
return ", ".join( map(str, self.cards) )
使用一个函数完成了冻结和备忘录模式,用另一个函数将Hand5
对象分离为两个子实例。
这样既可以增强可读性,也不必使用参数名称来解释接口意图。
以下代码段演示了我们如何把Hand5
对象进行分离:
d = Deck()
h = Hand5( d.pop(), d.pop(), d.pop() )
s1, s2 = Hand5.split( h, d.pop(), d.pop() )
我们创建了一个Hand5
类的h
实例,把它分为另外两个Hand
实例,名为s1
和s2
,然后分别为它们赋值。而使用__init__()
函数实现同样的功能时,split()
静态函数的实现版本简化了很多。然而它并没有遵守一个原则:使用已有的set
对象来创建fronzenset
对象。
__init__()
技术我们再来看一下其他一些更高级的__init__()
技术的应用。相比前面的介绍,它们的应用场景不是特别常见。
以下是Player类的定义,初始化使用了两个策略对象和一个table
对象。这个__init__()
函数看起来不够漂亮。
class Player:
def __init__( self, table, bet_strategy, game_strategy ):
self.bet_strategy = bet_strategy self.game_strategy = game_strategy self.table= table def game( self ):
self.table.place_bet( self.bet_strategy.bet() )
self.hand= self.table.get_hand()
if self.table.can_insure( self.hand ):
if self.game_strategy.insurance( self.hand ):
self.table.insure( self.bet_strategy.bet() )
# Yet more... Elided for now
Player
类中的__init__()
函数的行为似乎仅仅是保存对象。代码逻辑只是把参数的值复制到同样名称的变量中。如果我们有很多参数,复制逻辑会显得臃肿且重复。
我们可以像如下代码这样使用这个Player
类(和相关对象)。
table = Table()
flat_bet = Flat()
dumb = GameStrategy()
p = Player( table, flat_bet, dumb )
p.game()
我们可以通过把关键字参数值直接转换为内部变量,以提供一个非常短而且灵活的初始化方式。
以下是一种使用关键字参数值来创建Player
类的方式。
class Player2:
def __init__( self, **kw ):
"""Must provide table, bet_strategy, game_strategy."""
self.__dict__.update( kw )
def game( self ):
self.table.place_bet( self.bet_strategy.bet() )
self.hand= self.table.get_hand()
if self.table.can_insure( self.hand ):
if self.game_strategy.insurance( self.hand ):
self.table.insure( self.bet_strategy.bet() )
# etc.
为了换来简洁的代码,这种实现方式牺牲了大量的可读性。它使得代码意图变得模糊。
既然__init__()
函数缩减到了一行,函数的很多多余的重复逻辑也被拿掉了。然而这种多余也被转化为对象各自的构造函数表达式。既然我们不再使用位置参数,那么我们就需要为对象初始化表达式提供参数名,如以下代码段所示。
p2 = Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb )
为什么这样做?
这样的类设计非常容易扩展,我们几乎不用担心是否需要传入额外的参数给构造函数。
以下是调用的例子。
>>> p1= Player2( table=table, bet_strategy=flat_bet, game_
strategy=dumb)
>>>p1.game()
以下代码演示了这个设计带来的可扩展性。
>>> p2= Player2( table=table, bet_strategy=flat_bet, game_
strategy=dumb, log_name="Flat/Dumb" )
>>>p2.game()
我们添加了一个log_name
属性而并不需要修改类定义,这个属性或许可以用来进行统计分析。Player2.log_name
属性可以用于日志的注解或其他数据。
这里存在一个限制,我们只可以添加类内部不会发生冲突的参数名。在创建子类时需要了解类的实现,以避免关键字参数名冲突。由于**kw参数提供了很少的信息,我们不得不去知道它的实现细节。可在大多数情况下,我们要相信一个类并使用它而不是去查看它的实现细节。
这种基于关键字的初始化可以放在基类中实现,以简化子类。当新需求导致需要添加参数时,我们不必在每个子类中都实现一个__init__()
函数。
这种实现方式的弊端在于存在一些变量在子类中没有提供文档说明。当仅需要添加一个变量时,可能需要改变整个类层次结构。第1个变量添加之后往往还会需要第2个和第3个。在设计的开始,我们应当考虑设计一些灵活的子类,而不是完美的基类。
我们可以(而且应该)像下面代码段那样同时使用位置变量和关键字变量。
class Player3( Player ):
def __init__( self, table, bet_strategy, game_strategy, **extras
):
self.bet_strategy = bet_strategy
self.game_strategy = game_strategy
self.table= table
self.__dict__.update( extras )
这种方式看起来比完全开放的定义更明智。我们把必需的参数设为位置参数,把可选参数通过关键字参数传入。这也演示了如何通过 extra
关键字参数把可选参数传入__init__()
函数。
这样的灵活性,基于关键字的初始化依赖于我们是否已经定义了相对透明的类。这种实现需要特别关注一下命名,因为关键字参数名是开放式的,要避免调试过程中发生命名冲突。
需要类型验证的场景很少。从某种程度上说,这是对Python的误解。从概念上来看,类型验证是为了验证所有的参数类型是恰当的类型,而这里对“恰当”的定义往往作用不大。
这和验证对象是否符合其他标准是不同的,例如数字范围检查和防止无限循环。
在__init__()
函数中实现以下逻辑可能会带来问题。
class ValidPlayer:
def __init__( self, table, bet_strategy, game_strategy ):
assert isinstance( table, Table )
assert isinstance( bet_strategy, BettingStrategy )
assert isinstance( game_strategy, GameStrategy )
self.bet_strategy = bet_strategy
self.game_strategy = game_strategy
self.table= table
这里使用了isinstance()
函数检查了每个类型的合法性。
我们编写了玩牌游戏模拟器并通过不断地改变GameStrategy
类来进行实验。由于它们都很简单(只有4个函数),继承的好处不够凸显,我们可以单独定义每个子类而不再定义基类。
正如本例中所演示的,我们将不得不创建子类,目的只是为了通过初始化过程的错误检查,而未能从抽象基类继承到任何可用的代码。
其中一个最大的鸭子类型问题是关于数值类型的,不同的数值类型会在不同的上下文工作。试图验证参数类型也许会导致原本工作很好的一个数值类型不再工作。当试图验证时,在Python中我们有以下两种选择。
这两点基本表达了相同的意思。代码某一天可能会无效,要么是因为一个本该允许的类型被禁止了,要么是使用了被禁止的类型。
允许任何类型 一般在Python中允许使用任何类型。关于这一点,在第4章“抽象基类设计的一致性”中会再次回顾。 |
面临这样一个问题:为什么要限制未来潜在的使用场景?
而通常没有一个合理的理由来说明这一点。
为了不为以后的应用场景带来阻碍,可以考虑提供文档、测试和调试日志来帮助其他程序员理解哪些类型限制是可以被处理的。为了使工作量最小化,无论如何我们都必须提供文档、日志和测试用例。
以下是一段示例文档,用于说明类所需的参数。
class Player:
def __init__( self, table, bet_strategy, game_strategy ):
"""Creates a new player associated with a table,
and configured with proper betting and play strategies
:param table: an instance of :class:'Table'
:param bet_strategy: an instance of :class:'BettingStrategy'
:param game_strategy: an instance of :class:'GameStrategy'
"""
self.bet_strategy = bet_strategy
self.game_strategy = game_strategy
self.table= table
当使用这个类时,就会从文档得知类的参数需求。可以传入任何类型。如果类型和期望的类型不兼容,那么代码将会不工作。理想情况下,我们会使用文档测试(doctest)
和单元测试(unittest)
来发现这些异常的场景。
关于Python中的私有化可以概括为:大家都是成年人。
面向对象设计使得接口和实现有了很大的差别,这也是封装的意义。一个类封装了一种数据结构、一个算法和一个外部接口等,程序设计的目的是要把接口与实现分离。
然而,没有编程语言会暴露出所有设计的细节。对于Python,也是如此。
关于类设计的一个方面,这一点没有用代码演示:对象中有关私有(实现)和公有(接口)函数或属性的差异。有些编程语言只是在概念上支持私有(C++或Java是两个例子)已经很复杂了。这类语言中的访问修饰符包括了私有、保护、公有和“未指定”,可以理解为半私有。私有关键字经常被错误使用,为子类的定义带来了没必要的复杂性。
Python中私有的概念很简单,如下所示。
Python中的部分函数以_命名,标记为不完全公有。help()
函数通常会忽略这类函数。可以使用像Sphinx这样的工具从文档中查找出它们的命名。
Python的内部命名以__起始(和结尾)。这也是Python如何避免内部和外部应用程序发生冲突的方式。这些内部集合的命名方式完全只是参考。毕竟,没有必要在代码中试图使用__前缀来定义一个“超级私有”的属性或函数。如果这样做的话就为以后制造了一个潜在的麻烦,当新版本的Python发布并使用了同样命名的函数或属性时,就会有命名冲突。我们还有可能和新版本中的其他名称发生冲突。
Python中关于可见度的命名规则如下所示。
通常,Python中的命名是根据函数(或属性)的目的来定义的,并提供文档说明。通常接口函数会有说明文档以及文档测试的例子,而实现细节的函数就不必了,提供简单的说明就可以了。
对于刚接触Python的程序员,有时会对私有化不是很常用而感到惊讶。可对于已经熟悉Python的程序员也会同样惊讶于,为了不必要的私有和公有定义的顺序而浪费很多脑细胞。因为函数名和文档已经把意图描述的很明白了。
在本章中,我们回顾了几种__init__()
函数的设计方法。在下一章中,我们会介绍特殊方法,包括一些高级的方法。
Python中有一些特殊方法,它们允许我们的类和Python更好地集成。在标准库参考(Standard Library Reference)中,它们被称为基本特殊方法,是与Python的其他特性无缝集成的基础。
例如,我们用字符串来表示一个对象的值。Object
基类包含了__repr__()
和__str__()
的默认实现,它们提供了一个对象的字符串描述。遗憾的是,这些默认的实现不够详细。我们几乎总会想重写它们中的一个或两个。我们还会介绍__format__()
,它更加复杂一些,但是和上面两个方法的作用相同。
我们还会介绍其他的转换方法,尤其是__hash__()
、__bool__()
和__bytes__()
。这些方法可以把一个对象转换成一个数字、一个布尔值或者一串字节。例如,当我们实现了__bool__()
,我们就可以像下面这样在if语句中使用我们的对象:if someobject:
。
接下来,我们会介绍实现了比较运算符的几个特殊方法:__lt__()
、__le__()
、__eq__()
、__ne__()
、__gt__()
和__ge__()
。
当我们定义一个类时,几乎总是需要使用这些基本的特殊方法。
我们会在最后介绍__new__()
和__del__()
,因为它们的使用更加复杂,而且相比于其他的特殊方法,我们并不会经常使用它们。
我们会详细地介绍如何用这些特殊方法来扩展一个简单类。我们需要了解从object
继承而来的默认行为,这样,我们才能理解应该在什么时候使用重写,以及如何使用它。
__repr__()
和__str__()
方法对于一个对象,Python提供了两种字符串表示。它们和内建函数repr()
、str()
、print()
及string.format()
的功能是一致的。
str()
方法表示的对象对用户更加友好。这个方法是由对象的__str__
方法实现的。repr()
方法的表示通常会更加技术化,甚至有可能是一个完整的Python表达式。文档中写道:
对于大多数类型,这个方法会尝试给出和调用eval()
一样的结果。
这个方法是由__repr__()
方法实现的。
print()
函数会调用str()
来生成要输出的对象。format()
函数也可以使用这些方法。当我们使用{!r}
或者{!s}
格式时,我们实际上分别调用了__repr__()
或者__str__()
方法。下面我们先来看一下这些方法的默认实现。
下面是一个很简单的类。
class Card:
insure= False
def __init__( self, rank, suit ):
self.suit= suit
self.rank= rank
self.hard, self.soft = self._points()
class NumberCard( Card ):
def _points( self ):
return int(self.rank), int(self.rank)
我们定义了两个简单类,每个类包含4个属性。
下面是在命令行中使用NumberCard
类的结果。
>>> x=NumberCard( '2', '♣')
>>>str(x)
'<__main__.NumberCard object at 0x1013ea610>'
>>>repr(x)
'<__main__.NumberCard object at 0x1013ea610>'
>>>print(x)
<__main__.NumberCard object at 0x1013ea610>
可以看到,__str__()
和__repr__()
的默认实现并不能提供非常有用的信息。
在以下两种情况下,我们可以考虑重写__str__()
和__repr__()
。
__str__()
和__repr__()
正如我们在前面看到的,__str__()
和__repr__()
并没有提供有用的信息,我们几乎总是需要重载它们。下面是当对象中不包括集合时我们可以使用的一种方法。这些方法是我们前面定义的Card
类的方法。
def __repr__( self ):
return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
format(
__class__=self.__class__, **self.__dict__)
def __str__( self ):
return "{rank}{suit}".format(**self.__dict__)
这两个方法依赖于如何将对象的内部实例变量__dict__
传递给format()
函数。这种方式对于使用__slots__
的函数并不合适,通常来说,这些都是不可变的对象。在格式规范中使用名字可以让格式化更加可读,不过它也让格式化模板更长。以__repr__()
为例,我们传递了__dict__
和__class__
作为format()
函数的参数。
格式化模板使用了两种格式化的规范。
{__class__.__name__}
模板,有时候也被写成{__class__.__name__!s}
,提供了类名的简单字符串表示。{suit!r}
和{rank!r}
模板,它们都使用了!r
格式规范来给repr()
方法提供属性值。以__str__()
为例,我们只传递了对象的__dict__
,而内部则是隐式使用了{!s}
格式规范来提供str()
方法的属性值。
__str__()
和__repr__()
涉及集合的时候,我们需要格式化集合中的单个对象以及这些对象的整体容器。下面是一个包含__str__()
和__repr__()
的简单集合。
class Hand:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards= list(cards)
def __str__( self ):
return ", ".join( map(str, self.cards) )
def __repr__( self ):
return "{__class__.__name__}({dealer_card!r}, {_cards_str})".
format(
__class__=self.__class__,
_cards_str=", ".join( map(repr, self.cards) ),
**self.__dict__ )
__str__()
方法很简单。
1.调用map
函数对集合中的每个对象使用str()
方法,这会基于返回的字符串集合创建一个迭代器。
2.用",".join()
将所有对象的字符串表示连接成一个长字符串。
__repr__()
方法更加复杂。
1.调用map
函数对集合中的每个对象应用repr()
方法,这会基于返回的结果集创建一个迭代器。
2.使用".".join()
连接所有对象的字符串表示。
3.用__class__
、集合字符串和__dict__
中的不同属性创建一些关键字。我们将集合字符串命名为_card_str
,这样就不会和现有的属性冲突。
4.用"{__class__.__name__}({dealer_card!r}, {_cards_str})".format()
来连接类名和之前连接的对象字符串。我们使用!r
格式化来保证属性也会使用repr()
来转换。
在一些情况下,我们可以优化这个过程,让它更加简单。在格式化中使用位置参数可以在一定程度上简化模板字符串。
__format__()
方法string.format()
和内置的format()
函数都使用了__format__()
方法。它们都是为了获得给定对象的一个符合要求的字符串表示。
下面是给__format__()
传参的两种方式。
someobject.__format__("")
:当应用程序中出现format(someobject)
或者"{0}".format(someobject)
时,会默认以这种方式调用__format__()
。在这些情况下,会传递一个空字符串,__format__()
的返回值会以默认格式表示。someobject.__format__(specification)
:当应用程序中出现format (someobject, specification)
或者"{0:specification}".format (someobject)"
时,会默认以这种方式调用__format__()
。注意,"{0!r}".format()
和"{0!s}".format()
并不会调用__format__()
方法。它们会直接调用__repr__()
或者__str__()
。
当specification
是""
时,一种合理的返回值是return str(self)
,这为各种对象的字符串表示形式提供了明确的一致性。
在一个格式化字符串中,":"
之后的文本都属于格式规范。当我们写"{0:06.4f}"
时,06.4f
是应用在项目0上的格式规范。
Python标准库的6.1.3.1节定义了一个复杂的数值规范,它是一个包括9个部分的字符串。这就是格式规范的基本语法,它的语法如下。
[[fill]align][sign][#][0][width][,][.precision][type]
这些规范的正则表示如下。
re.compile(
r"(?P<fill_align>.?[\<\>=\^])?"
"(?P<sign>[-+ ])?"
"(?P<alt>#)?"
"(?P<padding>0)?"
"(?P<width>\d*)"
"(?P<comma>,)?"
"(?P<precision>\.\d*)?"
"(?P<type>[bcdeEfFgGnosxX%])?" )
这个正则表达式将规范分解为8个部分。第1部分同时包括了原本规范中的fill
和alignment
字段。我们可以利用它们定义我们的类中的数值类型的格式。
但是,Python格式规范的语法有可能不能很好地应用到我们之前定义的类上。所以,我们可能需要定义我们自己的规范化语法,并且使用我们自己的__format__()
方法来处理它。如果我们定义的是数值类型,那么我们应该使用Python中内建的语法。但是,对于其他类型,没有理由坚持使用预定义的语法。
例如,下面是我们自定义的一个微型语言,用%r
来表示rank
,用%s
来表示suit
,用%
代替%%
,所有其他的文本保持不变。
我们可以用下面的格式化方法扩展Card
类。
def __format__( self, format_spec ):
if format_spec == "":
return str(self)
rs= format_spec.replace("%r",self.rank).replace("%s",self.suit)
rs= rs.replace("%%","%")
return rs
方法签名中,需要一个format_spec
作为格式规范参数。如果没有提供这个参数,那么就会使用str()
函数来返回结果。如果提供了格式规范参数,就会用rank
、suit
和%
字符替换规范中对应的部分,来生成最后的结果。
这允许我们使用下面的方法来格式化牌。
print( "Dealer Has {0:%r of %s}".format( hand.dealer_card) )
其中,("%r of %s")
作为格式化参数传入__format__()
方法。通过这种方式,我们能够为描述自定义对象提供统一的接口。
或者,我们可以用下面的方法:
default_format= "some specification"
def __str__( self ):
return self.__format__( self.default_format )
def __format__( self, format_spec ):
if format_spec == "": format_spec = self.default_format
# process the format specification.
这种方法的优点是把所有与字符串表示相关的逻辑放在__format__()
方法中,而不是分别写在__format__()
和__str__()
里。但是,这样做有一个缺点,因为并非每次都需要实现__format__()
方法,但是我们总是需要实现__str__()
。
string.format()
方法可以处理{}中内嵌的实例,替换其中的关键字,生成新的格式规范。这种替换是为了生成最后传入__format__()
中的格式化字符串。通过使用这种内嵌的替换,我们可以使用一种更加简单的带参数的更加通用的格式规范,而不是使用相对复杂的数值格式。
下面是使用内嵌格式规范的一个例子,它让format
参数中的width
更容易改变:
width=6
for hand,count in statistics.items():
print( "{hand}{count:{width}d}".format(hand=hand,count=count,width= width) )
我们定义了一个通用的格式,"{hand}{count:{width}d}"
,它需要一个width
参数,才算是一个正确的格式规范。
通过width=
参数提供的值会被用来替换{width}
。替换完成后,完整的格式化字符串会作为__format__()
方法的参数使用。
当格式化一个包含集合的对象时,我们有两个难题:如何格式化整个对象和如何格式化集合中的对象。以Hand为例,其中包含了Cards类的集合。我们会更希望可以将Hand中一部分格式化的逻辑委托给Card实例完成。
下面是Hand中的format()方法。
def __format__( self, format_specification ):
if format_specification == "":
return str(self)
return ", ".join( "{0:{fs}}".format(c, fs=format_specification)
for c in self.cards )
Hand集合中的每个Card实例都会使用format_specification参数。对于每一个Card对象,都会使用内嵌格式规范的方法,用format_specification创建基于"{0:{fs}}"的格式。通过这样的方法,一个Hand对象,player_hand,可以以下面的方法格式化:
"Player: {hand:%r%s}".format(hand=player_hand)
这会将%r%s格式规范应用在Hand对象中的每个Card实例上。
__hash__()
方法内置的hash( )
函数默认调用了__hash__()
方法。哈希是一种将相对复杂的值简化为小整数的计算方式。理论上说,一个哈希值可以表示出源值的所有位。还有一些其他的哈希方法,会得出非常大的值,这样的算法通常用于密码学。
Python中有两个哈希库。其中,hashlib
可以提供密码级别的哈希函数,zlib模块包含两个高效的哈希函数:adler32()
和crc32()
。对于相对简单的值,我们不使用这些内置的函数,对于复杂的或者很大的值,这些内置的函数可以提供很大的帮助。
hash()
函数(以及与其相关联的__hash__()
方法)主要被用来创建set
、frozenset
和dict
这些集合类型的键。这些集合利用不可变对象的哈希值来高效地查找集合中的对象。
在这里,不可变性是非常重要的,我们还会多次提到它。不可变对象不会改变自己的状态。例如,数字3不会改变状态,它永远是3。对于更复杂的对象,同样可以有一个不变的状态。Python中的string
是不可变的,所以它们可以被用作map
和set
的键。
object
中默认的__hash__()
方法的实现是基于对象内部的ID值生成哈希值。这个ID值可以用id()
函数查看:
>>> x = object()
>>>hash(x)
269741571
>>>id(x)
4315865136
>>>id(x) / 16
269741571.0
可以看到,在笔者的系统中,哈希值是用对象的id除以16算出来的。对于不同的平台,哈希值的计算方法有可能不同。例如,CPython使用portable c库,而Jython则基于JVM。
这里最关键的是,在__hash__()
和内部的ID之间有很强的依赖关系。__hash__()
方法默认的行为是要保证每一个对象都是可哈希的,并且哈希值是唯一的,即使这些对象包含同样的值。
如果我们希望包含同样值的不同对象有相同的哈希值,就需要修改这个方法。在下一节中,我们会展示一个例子,这个例子中,具有相同值的两个Card
实例被当作相同的对象。
并非每个对象都需要提供一个哈希值,尤其是,当我们创建一个包含有状态、可改变对象的类时,这个类不应该返回哈希值。__hash__
的定义应该是None
。
另外,对于不可变的对象,可以显式地返回一个哈希值,这样这个对象就可以用作字典中的一个键或者集合中的一个成员。在这种情况下,哈希值需要和相等性判断的实现方式兼容。相同的对象返回不同的哈希值是很糟糕的实践。反之,具有相同哈希值的对象互相不等是可以接受的。
我们将在比较运算符一章中讲解的__eq__()
方法也和哈希有紧密的关联。
等价性比较有3个层次。
is
运算符。基本哈希法(Fundamental Law of Hash,FLH)定义如下:比较相等的对象的哈希值一定相同。
我们可以认为哈希比较是等价性比较的第1步。
反之则不成立,有相同哈希值的对象不一定相等。当创建集合或字典时,这带来了==预期的处理开销。我们没有办法从更大的数据结构中可靠地创建64位不同的哈希值,这时就会出现不同的对象的哈希值碰巧相等的情况。
巧合的是,当使用sets
和dicts
的时候,计算哈希值相等是预期的开销。这些集合中有一些内置的算法,当哈希值出现冲突的时候,它们会使用备用的位置。
对于以下3种情况,需要使用__eq__()
和__hash__()
方法来定义相等性测试和哈希值。
不可变对象:这些是不可以修改的无状态类型对象,例如tuple
、namedtuple
和frozenset
。我们针对这种情况有两个选择。
不用自定义__hash__()
和__eq__()
。这意味着直接使用继承而来的行为。这种情况下,__hash__()
返回一个简单的函数代表对象的ID值,然后__eq__()
比较对象的ID值。默认相等性测试的行为有时候比较反常。我们的应用程序可能会需要Card(1, Clubs)
的两个实例来测试相等性和计算哈希值,但是默认情况下这不会发生。
自定义__hash__()
和__eq__()
。请注意,这种自定义必须是针对不可变对象。
可变对象:这些都是有状态的对象,它们允许从内部修改。设计时,我们有一个选择如下。
__eq__()
,但是设置__hash__
为None
。这些对象不可以用作dict
的键和set
中的项目。除了上面的选择之外,还有一种可能的组合:自定义__hash__()
但使用默认的__eq__()
。但是,这简直是浪费代码,因为默认的__eq__()
方法和is
操作符是等价的。对于相同的行为,使用默认的__hash__()
方法只需要写更少的代码。
接下来,我们细致地分析一下以上3种选择。
首先,我们来看看默认行为是如何工作的。下面是一个使用了默认__hash__()
和__eq__()
的简单类。
class Card:
insure= False
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
def __repr__( self ):
return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
format(__class__=self.__class__, **self.__dict__)
def __str__( self ):
return "{rank}{suit}".format(**self.__dict__)
class NumberCard( Card ):
def __init__( self, rank, suit ):
super().__init__( str(rank), suit, rank, rank )
class AceCard( Card ):
def __init__( self, rank, suit ):
super().__init__( "A", suit, 1, 11 )
class FaceCard( Card ):
def __init__( self, rank, suit ):
super().__init__( {11: 'J', 12: 'Q', 13: 'K' }[rank], suit, 10, 10 )
这是一个基本的不可变对象的类结构。我们还没有实现防止属性更新的特殊方法。我们会在下一章中介绍属性访问。
接下来,我们使用之前定义的类。
>>> c1 = AceCard( 1, '♣' )
>>> c2 = AceCard( 1, '♣' )
我们定义了两个看起来一样的Card
实例。我们可以用下面的代码获得id()
的值。
>>>print( id(c1), id(c2) )
4302577232 4302576976
可以看到,它们的id()
值不同,说明它们是两个不同的对象。这正是我们期望的行为。
我们还可以用is
运算符检测它们是否相同。
>>>c1 is c2
False
“is测试”基于id()
的值,它表明,这两个对象确实是不同的。
我们可以看到,它们的哈希值也是不同的。
>>>print( hash(c1), hash(c2) )
268911077 268911061
这些哈希值是根据id()
的值计算出来的。对于继承的方法,这正是我们期望的行为。在这个例子中,我们可以用下面的代码用id()
计算出哈希值。
>>>id(c1) / 16
268911077.0
>>>id(c2) / 16
268911061.0
由于哈希值不同,因此它们比较的结果肯定不同。这符合哈希和相等性的定义。但是,这和我们对这个类的预期不同。下面是一个相等性测试。
>>>print( c1 == c2 )
False
我们之前用相等的参数创建了这两个对象,但是它们不相等。在一些应用程序中,这样的行为可能不是所期望的。例如,当统计庄家牌的点数时,我们不想因为使用了6副牌而把同一张牌统计6次。
可以看到,由于我们可以把它们存入set
中,因此它们一定是不可变对象。
>>>print( set( [c1, c2] ) )
{AceCard(suit='♣', rank=1), AceCard(suit='♣', rank=1)}
这是标准参考库中记录的行为。默认地,我们会得到一个基于对象ID值的__hash__()
方法,这样每一个实例都是唯一的。但我们并非总是需要这样的行为。
下面是一个重载了__hash__()
和__eq__()
定义的简单类。
class Card2:
insure= False
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
def __repr__( self ):
return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
format(__class__=self.__class__, **self.__dict__)
def __str__( self ):
return "{rank}{suit}".format(**self.__dict__)
def __eq__( self, other ):
return self.suit == other.suit and self.rank == other.rank
def __hash__( self ):
return hash(self.suit) ^ hash(self.rank)
class AceCard2( Card2 ):
insure= True
def __init__( self, rank, suit ):
super().__init__( "A", suit, 1, 11 )
原则上,这个对象应该是不可变的。但是,我们还没有引入让它成为真正的不可变对象的机制。在第3章中,我们会探讨如何防止属性值被改变。
同时,请注意,上述代码中省略了上个例子中的两个子类,因为它们的代码和之前一样。
__eq__()
方法比较了两个初始值:suit
和rank
,而没有比较对象中从rank
继承而来的值。
21点的规则让这样的定义看起来有些奇怪。在21点中,suit
并不重要。那么是不是我们只需要比较rank
就可以了?我们是否应该再定义一个方法只比较rank
?或者,我们是否应该相信应用程序可以用合适的方式比较rank
?对于这3个问题,没有最好的答案,因为这些都是权宜的方法。
hash()
方法函数通过对两个基本数字的所有位取异或计算出一种新的位模式。用^运算符是另外一种快速但不好的方法。对于更复杂的对象,最好能使用更合理的方法。在开始自己造轮子之前可以先看看ziplib
。
接下来,我们看看这些类的对象是如何工作的。我们预期它们是等价的,并且能够用于set
和dict
中。以下是两个对象。
>>> c1 = AceCard2( 1, '♣' )
>>> c2 = AceCard2( 1, '♣' )
我们定义了两个看起来似乎相同的对象。但是,通过查看ID的值,我们可以确保它们事实上是不同的。
>>>print( id(c1), id(c2) )
4302577040 4302577296
>>>print( c1 is c2 )
False
这两个对象的id()
返回值不同。如果用is
运算符比较它们,可以看到,它们是两个不同的对象。
接下来,我们比较它们的哈希值。
>>>print( hash(c1), hash(c2) )
1259258073890 1259258073890
可以看到,哈希值是相同的,也就是说它们有可能相等。
==运算符比较的结果和我们预期的一样,它们是相等的。
>>>print( c1 == c2 )
True
由于这两个都是不可变的对象,因此我们可以将它们放进set
里。
>>>print( set( [c1, c2] ) )
{AceCard2(suit='♣', rank='A')}
对于复杂的不可变对象,这样的行为和我们预期的一致。我们必须同时重载这两个特殊方法来使结果一致并且有意义。
这个例子会继续使用Cards
类。可变的牌听起来有些奇怪,甚至是错误的。但是,我们只会对前面的例子做一个小改变。
下面的类层次结构中,我们重载了可变对象的__hash__()
和__eq__()
。
class Card3:
insure= False
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
def __repr__( self ):
return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
format(__class__=self.__class__, **self.__dict__)
def __str__( self ):
return "{rank}{suit}".format(**self.__dict__)
def __eq__( self, other ):
return self.suit == other.suit and self.rank == other.rank
# and self.hard == other.hard and self.soft == other.soft
__hash__ = None
class AceCard3( Card3 ):
insure= True
def __init__( self, rank, suit ):
super().__init__( "A", suit, 1, 11 )
接下来,让我们看看这些类对象的行为。我们期望的行为是,它们在比较中是相等的,但是不可以用于set
和dict
。我们创建了如下两个对象。
>>> c1 = AceCard3( 1, '♣' )
>>> c2 = AceCard3( 1, '♣' )
我们再次定义了两个看起来相同的牌。
下面,我们看看它们的ID值,确保它们实际上是不同的两个实例。
>>>print( id(c1), id(c2) )
4302577040 4302577296
和我们预期的一样,它们的ID值不同。接下来,让我们看看是否可以获得哈希值。
>>>print( hash(c1), hash(c2) )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'
因为__hash__
被设为None
,所以这些用Card3
生成的对象不可以被哈希,也就无法通过hash()
函数提供哈希值了。这正是我们预期的行为。
我们可以用下面的代码比较这两个对象。
>>>print( c1 == c2 )
True
比较的结果和我们预期的一样,这样我们就仍然可以使用==来比较它们,只是这两个对象不可以存放在set
中或者用作dict
的键。
下面是当我们试图将这两个对象插入set
中时的结果。
>>>print( set( [c1, c2] ) )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'
当试图插入set
中时,我们得到了一个适当的异常。
很明显,对于生活中的一些不可变的对象,例如一张牌,这样的定义并不合适。这种定义方式更适合有状态的对象,例如Hand
,因为手中的牌时常改变。下面的部分,我们会展示第2个有状态对象的例子。
如果我们想要统计特定的Hand
实例,我们可能希望创建一个字典,然后将一个Hand
实例映射为一个计数。在映射中,不能使用一个可变的Hand
类作为键。但是,我们可以模仿set和frozenset
设计,定义两个类:Hand
和FrozenHand
。FrozenHand
允许我们“冻结”一个Hand
类,冻结的版本是不可变的,所以可以作为字典的键。
下面是一个简单的Hand
定义。
class Hand:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards= list(cards)
def __str__( self ):
return ", ".join( map(str, self.cards) )
def __repr__( self ):
return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
__class__=self.__class__,
_cards_str=", ".join( map(repr, self.cards) ),
**self.__dict__ )
def __eq__( self, other ):
return self.cards == other.cards and self.dealer_card ==
other.dealer_card
__hash__ = None
这是一个包含适当的相等性比较的可变对象(__hash__
是None
)。
下面是不可变的Hand版本。
import sys
class FrozenHand( Hand ):
def __init__( self, *args, **kw ):
if len(args) == 1 and isinstance(args[0], Hand):
# Clone a hand
other= args[0]
self.dealer_card= other.dealer_card
self.cards= other.cards
else:
# Build a fresh hand
super().__init__( *args, **kw )
def __hash__( self ):
h= 0
for c in self.cards:
h = (h + hash(c)) % sys.hash_info.modulus
return h
不变的版本中有一个构造函数,从另外一个Hand
类创建一个Hand
类。同时,还定义了一个__hash__()
方法,用sys.hash_info.modulus
的值来计算cards
的哈希值。大多数情况下,这种基于模计算复合对象哈希值的方法能够满足我们的要求。
现在我们可以开始使用这些类了,如下所示。
stats = defaultdict(int)
d= Deck()
h = Hand( d.pop(), d.pop(), d.pop() )
h_f = FrozenHand( h )
stats[h_f] += 1
我们初始化了一个数据字典——stats
,作为一个可以存储整数的defaultdict
字典。我们也可以用collections.Counter
对象作为这个字典。
Hand
类冻结后,我们就可以将它用作字典的键,用这个键对应的值来统计实际的出牌次数。
__bool__()
方法Python中有很多关于真假性的定义。参考手册中列举了许多和False
等价的值,包括False
、0
、''
、()
、[]
和{}
。其他大部分的对象都和True
等价。
通常,我们会用下面的语句来测试一个对象是否“非空”。
if some_object:
process( some_object )
默认情况下,这个是内置的bool()
函数的逻辑。这个函数依赖于一个给定对象的__bool__()
方法。
默认的__bool__()
方法返回True
。我们可以通过下面的代码来验证这一点。
>>> x = object()
>>>bool(x)
True
对大多数类来说,这是完全正确的。大多数对象都不应该和False
等价。但是,对于集合,这样的行为并不总是正确的。一个空集合应该和False
等价,而一个非空集合应该返回True
。或许,应该给我们的Deck
集合对象增加一个类似的方法。
如果我们在封装一个列表,我们可能会写下面这样的代码。
def __bool__( self ):
return bool( self._cards )
这段代码将__bool__()
的计算委托给了内部的集合_cards
。
如果我们在扩展一个列表,可能会写下面这样的代码:
def __bool__( self ):
return super().__bool__( self )
这段代码使用了基类中定义的__bool__()
函数。
在这两个例子中,我们都将布尔值的计算委托给其他对象。在封装的例子中,我们委托给了一个内部的集合。在扩展的例子中,我们委托给了基类。不管是封装还是扩展,一个空集合的布尔值都是False
。这会让我们很清楚Deck
对象是否已经被处理完了。
现在,我们就可以像下面这样使用Deck
。
d = Deck()
while d:
card= d.pop()
# process the card
这段代码会处理完Deck
中所有的牌,当所有的牌都处理完时,也不会抛出IndexError
异常。
__bytes__()
方法只有很少的情景需要我们把对象转换为字节。在第2部分“持久化和序列化”中,我们会详细探讨这个主题。
通常,应用程序会创建一个字符串,然后使用Python的IO类内置的编码方法将字符串转换为字节。对于大多数情况,这种方法就足够了。只有当我们自定义一种新的字符串时,我们会需要定义这个字符串的编码方法。
依据不同的参数,bytes()
函数的行为也不同。
bytes(integer)
:返回一个不可变的字节对象,这个对象包含了给定数量的0x00值。bytes(string)
:这个版本会将字符串编码为字节。其他的编码和异常处理的参数会定义编码的具体过程。bytes(something)
:这个版本会调用something.__bytes__()
创建字节对象。这里不用编码或者错误处理参数。基本的object
对象没有定义__bytes__()
。这意味着所有的类在默认情况下都没有提供__bytes__()
方法。
在一些特殊情况下,在写入文件之前,我们需要将一个对象直接编码成字节。通常使用字符串并且使用str类型为我们提供字符串的字节表示会更简单。要注意,当操作字节时,没有什么快捷方式可以解码文件或者接口中的字节。内置的bytes
类只能解码字符串,对于我们的自定义对象,是无法解码的。在这种情况下,我们需要解析从字节解码出来的字符串,或者我们可以显式地调用struct
模块解析字节,然后基于解析出来的值创建我们的自定义对象。
下面我们来看看如何把Card
编码和解码为字节。由于Card
只有52个可能的值,所以每一张牌都应该作为一个单独的字节。但是,我们已经决定用一个字符表示suit
,用另外一个字符表示rank
。此外,我们还需要适当地重构Card
的子类,所以我们必须对下面这些项目进行编码。
Card
的子类(AceCard
、NumberCard
、FaceCard
)。__init__()
参数。注意,我们有一些__init__()
方法会将一个数值类型的rank
转换为一个字符串,导致丢失了原始的数值。为了使字节编码可逆,我们需要重新创建rank
的原始数值。
下面是__bytes__()
的一种实现,返回了Card
、rank
和suit
的UTF-8编码。
def __bytes__( self ):
class_code= self.__class__.__name__[0]
rank_number_str = {'A': '1', 'J': '11', 'Q': '12', 'K': '13'}.get( self.rank, self.rank )
string= "("+" ".join([class_code, rank_number_str, self.suit,] ) + ")"
return bytes(string,encoding="utf8")
这种实现首先用字符串表示Card
对象,然后将字符串编码为字节。这通常是最简单也是最灵活的方法。
当我们拿到一串字节时,我们可以将这串字节解码为一个字符串,然后将字符串转换为一个新的Card
对象。下面是基于字节创建Card
对象的方法。
def card_from_bytes( buffer ):
string = buffer.decode("utf8")
assert string[0 ]=="(" and string[-1] == ")"
code, rank_number, suit = string[1:-1].split()
class_ = { 'A': AceCard, 'N': NumberCard, 'F': FaceCard }[code]
return class_( int(rank_number), suit )
在上面的代码中,我们将字节解码为一个字符串。然后我们将字符串解析为数值。基于这些值,现在我们可以重建原始的Card
对象。
我们可以像下面这样生成一个Card
对象的字节表示。
b= bytes(someCard)
然后我们可以用生成的字节重新创建Card
对象。
someCard = card_from_bytes(b)
需要特别注意的是,通常自己定义字节表示是非常有挑战性的,因为我们试图表示一个对象的状态。Python中已经内置了很多字节表示的方式,通常这些方法足够我们使用了。
如果需要定义一个对象底层的字节表示方式,最好使用pickle或者json模块。在第9章“序列化和保存——JSON、YAML、Pickle、CSV和XML”中,我们会详细探讨这个主题。
Python有6个比较运算符。这些运算符分别对应一个特殊方法的实现。根据文档,运算符和特殊方法的对应关系如下所示。
x < y
调用x.__lt__(y)
。x <=y
调用x.__le__(y)
。x == y
调用x.__eq__(y)
。x != y
调用x.__ne__(y)
。x > y
调用x.__gt__(y)
。x >= y
调用x.__ge__(y)
。我们会在第7章“创建数值类型”中再探讨比较运算符。
对于实际上使用了哪个比较运算符,还有一条规则。这些规则依赖于作为左操作数的对象定义需要的特殊方法。如果这个对象没有定义,Python会尝试改变运算顺序。
下面是两条基本的规则: 首先,运算符的实现基于左操作数:A < B相当于 A.__lt__(B) 。 其次,相反的运算符的实现基于右操作数:A < B相当于B.__gt__(A) 。如果右操作数是左操作数的一个子类,那这样的比较基本不会有什么异常发生;同时,Python会首先检测右操作数,以确保这个子类可以重载基类。 |
下面,我们通过一个例子看看这两条规则是如何工作的,我们定义了一个只包含其中一个运算符实现的类,然后把这个类用于另外一种操作。
下面是我们使用类中的一段代码。
class BlackJackCard_p:
def __init__( self, rank, suit ):
self.rank= rank
self.suit= suit
def __lt__( self, other ):
print( "Compare {0} < {1}".format( self, other ) )
return self.rank < other.rank
def __str__( self ):
return "{rank}{suit}".format( **self.__dict__ )
这段代码基于21点的比较规则,花色对于大小不重要。我们省略了比较方法,看看当缺少比较运算符时,Python将如何回退。这个类允许我们进行<比较。但是有趣的是,通过改变操作数的顺序,Python也可以使用这个类进行>比较。换句话说,x<y和y>x是等价的。这遵从了镜像反射法则;在第7章“创建数值类型”中,我们会再探讨这个部分。
当我们试图评估不同的比较运算时就会看到这种现象。下面,我们创建两个Cards
类,然后用不同的方式比较它们。
>>> two = BlackJackCard_p( 2, '♠' )
>>> three = BlackJackCard_p( 3, '♠' )
>>> two < three
Compare 2♠ < 3♠
True
>>> two > three
Compare 3♠ < 2♠
False
>>> two == three
False
>>> two <= three
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: BlackJackCard_p() <= BlackJackCard_p()
从代码中,我们可以看到,two < three
调用了two.__lt__(three)
。
但是,对于two > three
,由于没有定义__gt__()
,Python使用three.__lt__(two)
作为备用的比较方法。
默认情况下,__eq__()
方法从object
继承而来,它比较不同对象的ID值。当我们用于==或!=比较对象时,结果如下。
>>> two_c = BlackJackCard_p( 2, '♣' )
>>>two == two_c
False
可以看到,结果和我们预期的不同。所以,我们通常都会需要重载默认的__eq__()
实现。
此外,逻辑上,不同的运算符之间是没有联系的。但是从数学的角度来看,我们可以基于两个运算符完成所有必需的比较运算。Python没有实现这种机制。相反,Python默认认为下面的4组比较是等价的。
x < y ≡ y > x
x ≤ y ≡ y ≥ x
x = y ≡ y = x
x ≠ y ≡ y ≠ x
这意味着,我们必须至少提供每组中的一个运算符。例如,我可以提供__eq__()
、__ne__()
、__lt__()
和__le__()
的实现。
@functools.total_ordering
修饰符打破了这种默认行为的局限性,它可以从__eq__()
或者__lt__()
、__le__()
、__gt__()
和__ge__()
的任意一个中推断出其他的比较方法。在第7章“创建数值类型”中,我们会详细探讨这种方法。
当设计比较运算符时,要考虑两个因素。
对于一个有许多属性的类,当我们研究它的比较运算符时,通常会觉得有很明显的歧义。或许这些比较运算符的行为和我们的预期不完全相同。
再次考虑我们21点的例子。例如card1==card2
这样的表达式,很明显,它们比较了rank
和suit
,对吗?但是,这总是和我们的预期一致吗?毕竟,suit
对于21点中的比较结果没有影响。
如果我们想决定是否能分牌,我们必须决定下面两个代码片段哪一个更好。下面是第1个代码段。
if hand.cards[0] == hand.cards[1]
下面是第2个代码段。
if hand.cards[0].rank == hand.cards[1].rank
虽然其中一个更短,但是简洁的并不总是最好的。如果我们比较牌时只考虑rank
,那么当我们创建单元测试时会有问题,例如一个简单的TestCase.assertEqual()
方法就会接受很多不同的Cards
对象,但是一个单元测试应该只关注正确的Cards
对象。
例如card1 <= 7
,很明显,这个表达式想要比较的是rank
。
我们是否需要在一些比较中比较Cards
对象所有的属性,而在另一些比较中只关注rank
?如果我们想要按suit
排序需要做什么?而且,相等性比较必须同时计算哈希值。我们在哈希值的计算中使用了多个属性值,那么也必须在相等性比较中使用它们。在这种情况下,很明显相等性的比较必须比较完整的Card
对象,因为在计算哈希值时使用了rank
和suit
。
但是,对于Card
对象间的排序比较,应该只需要基于rank
。类似地,如果和整数比较,也应该只关注rank
。对于判断是否要发牌的情况,很明显,用hand.cards[0]. rank == hand.cards[1].rank
判断是很好的方式,因为它遵守了发牌的规则。
下面我们通过一个更完整的BlackJackCard
类来看一下简单的同类比较。
class BlackJackCard:
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
def __lt__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank < other.rank
def __le__( self, other ):
try:
return self.rank <= other.rank
except AttributeError:
return NotImplemented
def __gt__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank > other.rank
def __ge__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank >= other.rank
def __eq__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank == other.rank and self.suit == other.suit
def __ne__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank != other.rank and self.suit != other.suit
def __str__( self ):
return "{rank}{suit}".format( **self.__dict__)
现在我们定义了6个比较运算符。
我们已经展示了两种类型检查的方法:显式的和隐式的。显式的类型检查调用了isinstance()
。隐式的类型检查使用了一个try:
语句块。理论上,使用try:
语句块有一个小小的优点:它避免了重复的类名称。有的人完全可能会想创建一种和这个BlackJackCard
兼容的Card
类的变种,但是并没有适当地定义为一个子类。这时候使用isinstance()
有可能导致一个原本正确的类出现异常。
使用try:
语句块可以让一个碰巧也有一个rank
属性的类仍然可以正常工作。不用担心这样会带来什么难,因为它除了在此处被真正使用外,这个类在程序的其他部分都无法被正常使用。而且,谁会真的去比较一个Card
的实例和一个金融系统中恰好有rank
属性的类呢?
后面的例子中,我们主要会关注try:
语句块的使用。isinstance()
方法是Python中惯用的方式,而且也被广泛应用。我们通过显式地返回NotImplemented
告诉Python这个运算符在当前类型中还没有实现。这样,Python 可以尝试交换操作数的顺序来看看另外一个操作数是否提供了对应的实现。如果没有找到正确的运算符,那么Python会抛出TypeError
异常。
我们没有给出3个子类和工厂函数:card21()
的代码,它们作为本章的习题。
我们也没有给出类内比较的代码,这个我们会在下一个部分中详细讲解。用上面定义的这个类,我们可以成功地比较不同的牌。下面是一个创建并比较3张牌的例子。
>>> two = card21( 2, '♠' )
>>> three = card21( 3, '♠' )
>>> two_c = card21( 2, '♣' )
用上面定义的Cards
类,我们可以进行像下面这样的一系列比较。
>>> two == two_c
False
>>> two.rank == two_c.rank
True
>>> two< three
True
>>> two_c < three
True
这个类的行为与我们预期的一致。
我们会继续以BlackJackCard
类为例来看看当两个比较运算中的两个操作数属于不同的类时会发生什么。
下面我们将一个Card
实例和一个int
值进行比较。
>>> two = card21( 2, '♣' )
>>> two < 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() < int()
>>> two > 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() > int()
可以看到,这和我们预期的行为一致,BlackJackCard
的子类Number21Card
没有实现必需的特殊方法,所以产生了一个TypeError
异常。
但是,再考虑下面的两个例子。
>>> two == 2
False
>>> two == 3
False
为什么用等号比较可以返回结果呢?因为当Python遇到NotImplemented
的值时,会尝试交换两个操作数的顺序。在这个例子中,由于整型的值定义了一个int.__eq__()
方法,所以可以和一个非数值类型的对象比较。
接下来,我们定义Hand
类,这样它可以有意义地比较不同的类。和其他的比较一样,我们必须确定我们要比较的内容。
对于Hand
类之间相等性的比较,我们应该比较所有的牌。
而对于Hand
类之间顺序的比较,我们需要比较每一个Hand
对象的属性。对于与int
值的比较,我们应该将当前Hand
对象的总和与int
值进行比较。为了获得当前总和,我们需要弄清21点中硬总和与软总和的细微差别。
当手上有一张A牌时,下面是两种可能的总和。
也就是说,手中牌的总和不是简单地累加所有的牌面值。
首先,我们需要确定手中是否有A牌。然后,我们才能确定是否有一个可用的(小于或者等于21点)的软总和。否则,我们就要使用硬总和。
对于确定子类与基类的关系逻辑的实现是否依赖于isinstance()
,是判断多态使用是否合理的标志。通常,这样的做法不符合基本的封装原则。一个好的子类定义应该只依赖于相同的方法签名。理想状态下,类的定义是不可见的,我们也没有必要知道类内部的细节。而不合理的多态则会广泛地使用isinstance()
。在一些情况下,isinstance()
是必需的,尤其是当使用Python内置的类时。但是,我们不应该向内置类中追加任何方法函数,而且为了加入一个多态的方法而去使用继承也是不值得的。
在一些没有继承的特殊方法中,我们可以看到必须使用isinstance()
来实现不同类的对象间的交互。在下一个部分中,我们会展示在没有关系的类间使用isinstance()
的方法。
对于与Card相关的类,我们希望用一个方法(或者一个属性)就可以识别一张A牌,而不需要调用isinstance()
。这个方法是一个多态的辅助方法,它可以确保我们能够辨别不同的牌。
这里,我们有两个选择。
由于保险注的存在,有两个原因让我们检测是否有A牌。如果庄家牌是A牌,那么就会触发一个保险注。如果庄家或者玩家的手上有A牌,那么需要对比软总和与硬总和。
对于A牌而言,硬总和与软总和总是需要通过card.soft-card.hard
的值来区分。仔细看看AceCard
的定义就可以知道这个值是10。但是,仔细地分析这个类的实现,我们就会发现这个版本的实现会破坏封装性。
我们可以把BlackJackCard
看作不可见的,所以我们仅仅需要比较card.soft- card.hard!=0
的值是否为真。如果结果为真,那么我们就可以用硬总和与软总和算出手中牌的总和。
下面是total
方法的一种实现,它使用硬总和与软总和的差值计算出当前手中牌的总和。
def total( self ):
delta_soft = max( c.soft-c.hard for c in self.cards )
hard = sum( c.hard for c in self.cards )
if hard+delta_soft <= 21: return hard+delta_soft
return hard
我们用delta_soft
记录硬总和与软总和之间的最大差值。对于其他牌而言,这个差值是0。但是对于A牌,这个差值不是0。
得到了delta_soft
和硬总和之后,我们就可以决定返回值是什么。如果hard + delta_soft
小于或者等于21,那么就返回软总和。如果软总和大于21,那么就返回硬总和。
我们可以考虑把21定义为宏。有时候一个有意义的名字比一个字面值更有用。但是,因为21在21点中几乎不可能变成其他值,所以很难找到其他比21更有意义的名字。
定义了Hand
对象的总和之后,我们可以合理地定义Hand
实例间的比较函数和Hand
与int
间的比较函数。为了确定我们在进行哪种类型的比较,必须使用isinstance()
。
下面是定义了比较方法的Hand
类的部分代码。
class Hand:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards= list(cards)
def __str__( self ):
return ", ".join( map(str, self.cards) )
def __repr__( self ):
return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
__class__=self.__class__,
_cards_str=", ".join( map(repr, self.cards) ),
**self.__dict__ )
def __eq__( self, other ):
if isinstance(other,int):
return self.total() == other
try:
return (self.cards == other.cards
and self.dealer_card == other.dealer_card)
except AttributeError:
return NotImplemented
def __lt__( self, other ):
if isinstance(other,int):
return self.total() < other
try:
return self.total() < other.total()
except AttributeError:
return NotImplemented
def __le__( self, other ):
if isinstance(other,int):
return self.total() <= other
try:
return self.total() <= other.total()
except AttributeError:
return NotImplemented
__hash__ = None
def total( self ):
delta_soft = max( c.soft-c.hard for c in self.cards )
hard = sum( c.hard for c in self.cards )
if hard+delta_soft <= 21: return hard+delta_soft
return hard
这里我们只定义了3个比较方法。
为了和Hand
对象交互,我们需要一些Card
对象。
>>> two = card21( 2, '♠' )
>>> three = card21( 3, '♠' )
>>> two_c = card21( 2, '♣' )
>>> ace = card21( 1, '♣' )
>>> cards = [ ace, two, two_c, three ]
我们会把这些牌用于两个不同Hand
对象。
第1个Hand对象有一张不相关的庄家牌和我们上面创建的4张牌,包括一张A牌:
>>> h= Hand( card21(10,'♠'), *cards )
>>> print(h)
A♣, 2♠, 2♣, 3♠
>>> h.total()
18
软总和是18,硬总和是8。
下面是第2个Hand
对象,除了上面第1个Hand
对象的4张牌,还包括了另一张牌。
>>> h2= Hand( card21(10,'♠'), card21(5,'♠'), *cards )
>>> print(h2)
5♠, A♣, 2♠, 2♣, 3♠
>>> h2.total()
13
硬总和是13,由于总和超过了21点,所以没有软总和。
从下面的代码中可以看到,Hand
对象之间的比较结果和我们预期的一致。
>>> h < h2
False
>>> h > h2
True
我们可以用比较运算符对Hand
对象排序。
我们也可以像下面这样把Hand
对象和int
比较。
>>> h == 18
True
>>> h < 19
True
>>> h > 17
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Hand() > int()
只要Python没有强制使用后备的比较方法,Hand
对象和整数的比较就可以很好地工作。上面的例子也展示了当没有定义__gt__()
方法时会发生什么。Python检查另一个操作数,但是整数17也没有任何与Hand
相关的__lt__()
方法定义。
我们可以添加必要的__gt__()
和__ge__()
函数,这样Hand
就可以很好地与整数进行比较。
__del__()
方法__del__()
方法有一个让人费解的使用场景。
这个方法的目的是在将一个对象从内存中清除之前,可以有机会做一些清理工作。如果使用上下文管理对象或者with
语句来处理这种需求会更加清晰,这也是第5章“可调用对象和上下文的使用”的内容。对于Python的垃圾回收机制而言,创建一个上下文比使用__del__()
更加容易预判。
但是,如果一个Python对象包含了一些操作系统的资源,__del__()
方法是把资源从程序中释放的最后机会。例如,引用了一个打开的文件、安装好的设备或者子进程的对象,如果我们将资源释放作为__del__()
方法的一部分实现,那么我们就可以保证这些资源最后会被释放。
很难预测什么时候__del__()
方法会被调用。它并不总是在使用del
语句删除对象时被调用,当一个对象因为命名空间被移除而被删除时,它也不一定被调用。Python文档中用不稳定来描述__del__()
方法的这种行为,并且提供了额外的关于异常处理的注释:运行期的异常会被忽略,相对地,会使用sys.stderr
打印一个警告。
基于上面的这些原因,通常更倾向于使用上下文管理器,而不是实现__del__()
。
CPython的实现中,对象会包括一个引用计数器。当对象被赋值给一个变量时,这个计数器会递增;当变量被删除时,这个计数器会递减。当引用计数器的值为0时,表示我们的程序不再需要这个对象并且可以销毁这个对象。对于简单对象,当执行删除对象的操作时会调用__del__()
方法。
对于包含循环引用的复杂对象,引用计数器有可能永远也不会归零,这样就很难让__del__()
被调用。
我们用下面的一个类来看看这个过程中到底发生了什么。
class Noisy:
def __del__( self ):
print( "Removing {0}".format(id(self)) )
我们可以像下面这样创建和删除这个对象。
>>> x= Noisy()
>>>del x
Removing 4313946640
我们先创建,然后删除了Noisy
对象,几乎是立刻就看到了__del__()
方法中输出的消息。这也就是说当变量x
被删除后,引用计数器正确地归零了。一旦变量被删除,就没有任何地方引用Noisy
实例,所以它也可以被清除。
下面是浅复制中一种常见的情形。
>>> ln = [ Noisy(), Noisy() ]
>>> ln2= ln[:]
>>> del ln
Python没有响应del
语句。这说明这些Noisy
对象的引用计数器还没有归零,肯定还有其他地方引用了它们,下面的代码验证了这一点。
>>> del ln2
Removing 4313920336
Removing 4313920208
ln2
变量是ln
列表的一个浅复制。有两个列表引用了Noisy
对象,所以在这两个列表被删除并且引用计数器归零之前,Python不会销毁这两个Noisy
对象。
还有很多种创建浅复制的方法。下面是其中的一些。
a = b = Noisy()
c = [ Noisy() ] * 2
这里的关键是,由于浅复制在Python中非常普遍,所以我们往往对存在的对象的引用感到非常困惑。
下面是一种常见的循环引用的情形。一个父类包含一个子类的集合,同时集合中的每个子类实例又包含父类的引用。
下面我们用这两个类来看看循环引用。
class Parent:
def __init__( self, *children ):
self.children= list(children)
for child in self.children:
child.parent= self
def __del__( self ):
print( "Removing {__class__.__name__} {id:d}".
format( __class__=self.__class__, id=id(self)) )
class Child:
def __del__( self ):
print( "Removing {__class__.__name__} {id:d}".
format( __class__=self.__class__, id=id(self)) )
一个Parent
的instance
包括一个children
的列表。
每一个Child
的实例都有一个指向Parent
类的引用。当向Parent
内部的集合中插入新的Child
实例时,这个引用就会被创建。
我们故意把这两个类写得比较复杂,所以下面让我们看看当试图删除对象时,会发生什么。
>>>> p = Parent( Child(), Child() )
>>> id(p)
4313921808
>>> del p
Parent
和它的两个初始Child
实例都不能被删除,因为它们之间互相引用。
下面,我们创建一个没有Child
集合的Parent
实例。
>>> p= Parent()
>>> id(p)
4313921744
>>> del p
Removing Parent 4313921744
和我们预期的一样,这个Parent
实例成功地被删除了。
由于互相之间有引用存在,因此我们不能从内存中删除Parent
实例和它包含的Child
实例的集合。如果我们导入垃圾回收器的接口——gc
,我们就可以回收和显示这些不能被删除的对象。
下面的代码中,我们使用了gc.collect()
方法回收所有定义了__del__()
方法但是无法被删除的对象。
>>> import gc
>>> gc.collect()
174
>>> gc.garbage
[<__main__.Parent object at 0x101213910>, <__main__.Child object at 0x101213890>, <__main__.Child object at 0x101213650>, <__main__.Parent object at 0x101213850>, <__main__.Child object at 0x1012130d0>, <__main__.Child object at 0x101219a10>, <__main__.Parent object at 0x101213250>, <__main__.Child object at 0x101213090>, <__main__.Child object at 0x101219810>, <__main__.Parent object at 0x101213050>, <__main__.Child object at 0x101213210>, <__main__.Child object at 0x101219f90>, <__main__.Parent object at 0x101213810>, <__main__.Child object at 0x1012137d0>, <__main__.Child object at 0x101213790>]
可以看到,我们的Parent
对象(例如,4313921808的ID = 0x101213910)在不可删除的垃圾对象列表中很突出。为了让引用计数器归零,我们需要删除所有Parent
对象中的children
列表,或者删除所有Child
实例中对Parent的引用。
注意,即使把清理资源的代码放在__del__()
方法中,我们也没办法解决循环引用的问题。因为__del__()
方法是在循环引用被解除并且引用计数器已经归零之后被调用的。当有循环引用时,我们不能只是简单地依赖于Python中计算引用数量的机制来清理内存中的无用对象。我们必须显式地解除循环引用或者使用可以保证垃圾回收的weakref引用。
如果我们需要循环引用,但是又希望将清理资源的代码写在__del__()
中,这时候我们可以使用弱引用。循环引用的一个常见场景是互相引用:一个父类中包含了一个集合,集合中的每一个实例也包含了一个指向父类的引用。如果一个Player
对象中包含多个Hand
实例,那么在每一个Hand
对象中都包括一个指向对应的Player
类的引用可能会更方便。
默认的对象间的引用可以被称为强引用,但是,叫直接引用可能更好。Python的引用计数机制会直接使用它们,而且如果引用计数无法删除这些对象的话,垃圾回收机器也能及时发现。它们是不可忽略的对象。
对一个对象的强引用就是直接引用,下面是一个例子。
当我们遇到如下语句。
a= B()
变量a直接引用了B类的一个对象。此时B的引用计数至少是1,因为a变量包含了一个指向它的引用。
想要找个一个弱引用相关的对象需要两个步骤。一个弱引用会调用x.parent()
,这个函数将弱引用作为一个可调用对象来查找它真正的父对象。这个过程让引用计数器得以归零,垃圾回收器可以回收引用的对象,但是不回收这个弱引用。
weakref
定义了一系列使用了弱引用而没有使用强引用的集合。它让我们可以创建一种特殊的字典类型,当这种字典的对象没有用时,可以保证被垃圾回收。
我们可以修改Parent
和Child
类,在Child
指向Parent
的引用中使用弱引用,这样就可以简单地保证无用对象会被销毁。
下面是修改后的类,它在Child
指向Parent
的引用中使用了弱引用。
import weakref
class Parent2:
def __init__( self, *children ):
self.children= list(children)
for child in self.children:
child.parent= weakref.ref(self)
def __del__( self ):
print( "Removing {__class__.__name__} {id:d}".format( __class__= self.__class__, id=id(self)) )
我们将child
中的parent
引用改为一个weakref
对象的引用。
在Child
类中,我们必须用上面说的两步操作来定位parent
对象:
p = self.parent()
if p is not None:
# process p, the Parent instance
else:
# the parent instance was garbage collected.
我们可以显式地确认引用的对象是否已经找到,因为有可能该引用已经变成虚引用。
当我们使用这个新的Parent2
类时,可以看到引用计数成功地归零同时对象也被删除了:
>>> p = Parent2( Child(), Child() )
>>> del p
Removing Parent2 4303253584
Removing Child 4303256464
Removing Child 4303043344
当一个weakref
引用变成死引用时(因为引用被销毁了),我们有3个可能的方案。
warnings
模块记录调试信息。通常,weakref
引用变成死引用是因为响应的对象已经被删除了。例如,变量的作用域已经执行结束,一个没有用的命名空间,应用程序正在关闭。对于这个原因,通常我们会采取第3种响应方法。因为试图创建这个引用的对象时很可能马上就会被删除。
__del__()
和close()
方法__del__()
最常见的用途是确保文件被关闭。
通常,包含文件操作的类都会有类似下面这样的代码。
__del__ = close
这会保证__del__()
方法同时也是close()
方法。
其他更复杂的情况最好使用上下文管理器。详情请看第5章“可调用对象和上下文的使用”,我们会在第5章提供更多和上下文管理器有关的信息。
__new__()
方法和不可变对象__new__
方法的一个用途是初始化不可变对象。__new__()
方法中允许创建未初始化的对象。这允许我们在__init__()
方法被调用之前先设置对象的属性。
由于不可变类的__init__()
方法很难重载,因此__new__
方法提供了一种扩展这种类的方法。
下面是一个错误定义的类,我们定义了float
的一个包含单位信息的版本。
class Float_Fail( float ):
def __init__( self, value, unit ):
super().__init__( value )
self.unit = unit
我们试图(不合理地)初始化一个不可变对象。
下面是当我们试图使用这个类时会发生的情况。
>>> s2 = Float_Fail( 6.5, "knots" )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: float() takes at most 1 argument (2 given)
可以看到,对于内置的float
类,我们不能简单地重载__init__
方法。对于其他的内置不可变类型,也有类似的问题。我们不能在不可变对象self
上设置新的属性值,因为这是不可变性的定义。我们只能在对象创建的过程中设置属性值,对象创建之后__new__()
方法就会被调用。
__new__()
方法天生就是一个静态方法。即使没有使用@staticmethod
修饰符,它也是静态的。它没有使用self
变量,因为它的工作是创建最终会被赋值给self
变量的对象。
这种情况下,我们会使用的方法签名是__new__( cls, *args, **kw)
。cls
变量是准备创建的类的实例。下一个部分关于元类型的例子,会比这里展示的args
的参数序列更加复杂。
__new__()
方法的默认实现如下。
return super().__new__( cls )
将调用基类的__new__()
方法创建对象。这个工作最终委托给了object.__new__()
,这个方法创建了一个简单的空对象。除了cls
以外,其他的参数和关键字最终都会传递给__init__()
方法,这是Python定义的标准行为。
除了有下面的两个例外,这就是我们期望的行为。
当创建一个内置的不可变类型的子类时,不能重载__init__()
方法。取而代之的是,我们必须通过重载__new__()
方法在对象创建的过程中扩展基类的行为。下例是扩展float
类的正确方式。
class Float_Units( float ):
def __new__( cls, value, unit ):
obj= super().__new__( cls, value )
obj.unit= unit
return obj
上面的代码在对象创建的过程中设置了一个属性的值。
下面的代码使用上面定义的类创建了一个带单位的浮点数。
>>>speed= Float_Units( 6.5, "knots" )
>>>speed
6.5
>>>speed * 10
65.0
>>> speed.unit
'knots'
注意,像speed * 10
这种表达式不会创建一个Float_Units
对象。这个类的定义继承了float
中所有的运算符;float
的所有算术特殊方法也都只会创建float
对象。创建Float_Units
对象会在第7章“创建数值类型”中介绍。
__new__()
方法和元类型__new__()
方法的另一种用途,作为元类型的一部分,主要是为了控制如何创建一个类。这和之前的如何用__new__()
控制一个不可变对象是完全不同的。
一个元类型创建一个类。一旦类对象被创建,我们就可以用这个类对象创建不同的实例。所有类的元类型都是type
,type()
函数被用来创建类对象。
另外,type()
函数还可以被用作显示当前对象类型。
下面是一个很简单的例子,直接使用type()
作为构造器创建了一个新的但是几乎完全没有任何用处的类:
Useless= type("Useless",(),{})
一旦我们创建了这个类,我们就可以开始创建这个类的对象。但是,这些对象什么都做不了,因为我们没有定义任何方法和属性。
为了最大化利用这个类,在下面的例子中,我们使用这个新创建的Useless
类来创建对象。
>>> Useless()
<__main__.Useless object at 0x101001910>
>>> u=_
>>> u.attr= 1
>>> dir(u)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__',
'__eq__', '__format__', '__ge__', '__getattribute__', '__gt__',
'__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__',
'__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'attr']
我们可以向这个类的对象中增加属性。至少,作为一个对象,它工作得很好。
这样的类定义与使用types.SimpleNamespace
或者像下面这样定义一个类的方式几乎相同。
class Useless:
pass
这带来一个重要的问题:为什么我们一开始要复杂化定义一个类的方法呢?
答案是,类中一些默认的特性无法应用到一些特殊的类上。下面,我们会列举4种应该使用元类型的场景。
__new__()
方法来确定子类的完整性。在第4章“抽象基类设计的一致性”中,我们会介绍这点。这是Python Language Reference 3.3.3节“自定义Python的类创建”中的经典例子,这个元类型会记录属性和方法的定义顺序。
下面是实现的3个具体步骤。
1.创建一个元类型。元类型的__prepare__()
和__new__()
方法会改变目标类创建的方式,会将原本的dict
类替换为OrderedDict
类。
2.创建一个基于此元类型的抽象基类。这个抽象类简化了其他类继承这个元类型的过程。
3.创建一个继承于这个抽象基类的子类,这样它就可以获得元类型的默认行为。
下面是使用该元类型的例子,它将保留属性创建的顺序。
import collections
class Ordered_Attributes(type):
@classmethod
def __prepare__(metacls, name, bases, **kwds):
return collections.OrderedDict()
def __new__(cls, name, bases, namespace, **kwds):
result = super().__new__(cls, name, bases, namespace)
result._order = tuple(n for n in namespace if not
n.startswith('__'))
return result
这个类用自定义的__prepare__()
和__new__()
方法扩展了内置的默认元类型type
。
__prepare__()
方法会在类创建之前执行,它的工作是创建初始的命名空间对象,类定义最后被添加到这个对象中。这个方法可以用来处理任何在类的主体开始执行前需要的准备工作。
__new__()
静态方法在类的主体被加入命名空间后开始执行。它的参数是要创建的类对象、类名、基类的元组和创建好的命名空间匹配对象。这个例子很经典:它将__new__()
的真正工作委托给了基类;一个元类型的基类是内置的type
;然后我们使用type.__new__()
创建一个稍后可以修改的默认类。
这个例子中的__new__()
方法向类中增加了一个_order
属性,用于存储原始的属性创建顺序。
当我们定义新的抽象基类时,我们可以用这个元类型而非type。
class Order_Preserved( metaclass=Ordered_Attributes ):
pass
然后,我们可以将这个新的抽象基类作为任何其他自定义类的基类,如下所示。
class Something( Order_Preserved ):
this= 'text'
def z( self ):
return False
b= 'order is preserved'
a= 'more text'
我们可以用下面的代码来介绍Something
类的使用。
>>> Something._order
>>> ('this', 'z', 'b', 'a')
我们可以考虑利用这些信息来正确序列化对象或者用于提供原始代码定义的调试信息。
接下来,我们看看一个关于单位换算的例子。例如,长度单位包括米、厘米、英寸、英尺和许多其他的单位。正确地管理单位换算是非常有挑战性的。表面上看,我们需要一个表示不同单位间转换因子的矩阵。例如,英尺转换为米、英尺转换为英寸、英尺转换为码、米转换为英寸、米转换为码等可能的组合。
但是,在实践中,一个更好的方案是定义一个长度的标准单位。我们可以把任何其他单位转换为标准单位,也可以把标准单位转换为任何其他单位。通过这种方式,我们可以很容易地将单位转换变成一致的两步操作,而不用再考虑包含了所有可能转换的复杂矩阵:英尺转换为标准单位,英寸转换为标准单位,码转换为标准单位,米转换为标准单位。
在下面的例子中,我们不准备继承float
或者numbers.Number
。相比于将单位和数值绑定在一起,我们更倾向于允许让每一个值仅仅代表一个简单的数字。这是享元模式的一个例子,类中不会定义包含相关值的对象,对象中仅仅包括转换因子。
另一种方案(将值和单位绑定)会造成需要相当复杂的三围分析。虽然这很有趣,但是太复杂了。
我们会定义两个类:Unit
和Standard_Unit
。我们可以很容易保证每个Unit
类中都正确地包含一个指向它的Standard_Unit
的引用。但是,我们如何能够保证每一个Standard_Unit
类中都有一个指向自己的引用呢?在类定义中实现子引用是不可能的,因为此时都还没有定义类。
下面是我们的Unit类的定义。
class Unit:
"""Full name for the unit."""
factor= 1.0
standard= None # Reference to the appropriate StandardUnit
name= "" # Abbreviation of the unit's name.
@classmethod
def value( class_, value ):
if value is None: return None
return value/class_.factor
@classmethod
def convert( class_, value ):
if value is None: return None
return value*class_.factor
这个类的目的是Unit.value()
可以将一个值从给定的单位转换为标准单位,而Unit.convert()
方法可以将一个值从标准单位转换为给定的单位。
这让我们可以用下面的方式转换单位。
>>> m_f= FOOT.value(4)
>>> METER.convert(m_f)
1.2191999999999998
创建的值类型是内置的float
类型。对于温度的计算,我们需要重载默认的value()
和convert()
方法,因为简单的乘法运算不能满足实际物景。
对于Standard_Unit
,我们可能会使用下面这样的代码:
class INCH:
standard= INCH
但是,这段代码无效。因为INCH
还没有定义在INCH
类中。在完成定义之前,这个类都是不存在的。
我们可以用下面的备用方法来处理这种情况。
class INCH:
pass
INCH.standard= INCH
但是,这样的做法相当丑陋。
我们还可以像下面这样定义一个修饰符。
@standard
class INCH:
pass
这个修饰符方法可以用来向类定义中加入一个属性。在第8章“装饰器和mixin——横切方面”中,我们再详细探讨这种方法。
现在,我们会定义一个可以向类定义中插入一个循环引用的元类型,如下所示。
class UnitMeta(type):
def __new__(cls, name, bases, dict):
new_class= super().__new__(cls, name, bases, dict)
new_class.standard = new_class
return new_class
这段代码强制地将变量standard
作为类定义的一部分。
对大多数单位,SomeUnit.standard
引用了TheStandardUnit
类。类似地,我们也让TheStandardUnit.standard
引用TheStandardUnit
类。Unit
和Standard_Right click for menu to add groups and entries. Edit or re-order any item. Use right click in editor to select which entry to paste.Unit
类之间这种一致的结构能够帮助我们书写文档和自动化单位转换。
下面是Standard_Unit
类:
class Standard_Unit( Unit, metaclass=UnitMeta ):
pass
从Unit
继承的单位转换因子是1.0,所以它并没有提供任何值。它包括了特殊的元类型定义,这样它就会有自引用,这个自引用表明这个类是这一特定维度的测量标准。
作为一种优化的手段,我们可以重载value()
和convert()
方法来禁止乘法和除法运算。
下面是一些单位类的例子。
class INCH( Standard_Unit ):
"""Inches"""
name= "in"
class FOOT( Unit ):
"""Feet"""
name= "ft"
standard= INCH
factor= 1/12
class CENTIMETER( Unit ):
"""Centimeters"""
name= "cm"
standard= INCH
factor= 2.54
class METER( Unit ):
"""Meters"""
name= "m"
standard= INCH
factor= .0254
我们将INCH定为标准单位,其他单位需要转换成英寸或者从英寸转换而来。
在每一个单位类中,我们都提供了一些文档信息:全名写在docstring
中并且用name
属性记录缩写。从Unit
继承而来的convert()
和value()
方法会自动应用转换因子。
有了这些类的定义,我们就可以在程序中像下面这样编码。
>>> x_std= INCH.value( 159.625 )
>>> FOOT.convert( x_std )
13.302083333333332
>>> METER.convert( x_std )
4.054475
>>> METER.factor
0.0254
我们可以根据给定的英寸值设置一种特定的测量方式并且可以将该值转换为任何兼容的单位。
由于元类型的存在,我们可以像下面这样从单位类中查询。
>>> INCH.standard.__name__
'INCH'
>>> FOOT.standard.__name__
'INCH'
这种引用方式让我们可以追踪一个指定维度上的不同单位。
我们已经介绍了许多基本的特殊方法,它们是我们在设计任何类时的基本特性。这些方法已经包含在每个类中,只是它们的默认行为不一定能满足我们的需求。
我们几乎总是需要重载__repr__()
、__str__()
、和__format__()
。这些方法的默认实现不是非常有用。
我们几乎不需要重载__bool__()
方法,除非我们想自定义集合。这是第6章“创建容器和集合”的主题。
我们常常需要重载比较运算符和__hash__()
方法。默认的实现只适合于比较简单不可变对象,但是不适用于比较可变对象。我们不一定要重写所有的比较运算符,在第8章“装饰器和mixin——横切方面”中,我们会详细介绍functools. total_ordering
修饰符。
另外两个较为特殊的方法__new__()
和__del__()
有更特殊的用途。大多数情况下,使用__new__()
来扩展不可变类型。
基本的特殊方法和__init__()
方法几乎会出现在我们定义的所有类中。其他的特殊方法则有更特殊的用途,它们分为6个不同的类别。
object.attribute
的部分,它通常用在一个赋值语句的左操作数以及del
语句中。len()
函数。sequence[index]
、mapping[index]
和set | set
。with
语句一起使用的上下文管理器。在下一章中,我们会着重探讨属性、特性和修饰符。