有许多特殊方法允许类和 Python 内置类之间的紧密集成。Python 标准库将它们称为basic。更好的术语可能是基础或基本。这些特殊的方法为构建与其他 Python 特性无缝集成的类奠定了基础。
例如,我们通常需要给定对象值的字符串表示。基类object
具有提供对象字符串表示的__repr__()
和__str__()
的默认实现。可悲的是,这些默认表示非常缺乏信息。我们几乎总是希望覆盖其中一个或两个默认定义。我们还将关注__format__()
,它有点复杂,但用途类似。
我们还将研究其他转换,特别是__hash__()
、__bool__()
和__bytes__()
。这些方法将对象转换为数字、真/假值或字节字符串。例如,当我们实现__bool__()
时,我们可以在if
语句中使用一个对象x
,如下所示:
if x:
bool()
函数有一个隐式用法,它依赖于对象对__bool__()
特殊方法的实现。
然后,我们可以看看实现__lt__()
、__le__()
、__eq__()
、__ne__()
、__gt__()
和__ge__()
比较运算符的特殊方法。
在类定义中几乎总是需要这些基本的特殊方法。
最后我们将讨论__new__()
和__del__()
,因为这些方法的用例相当复杂。我们不需要像需要其他基本的特殊方法那样经常使用这些方法。
我们将详细介绍如何扩展一个简单的类定义来添加这些特殊方法。我们需要查看从object
继承的两种默认行为,以便了解需要什么样的重写以及何时需要重写。
在本章中,我们将介绍以下主题:
__repr__()
和__str__()
方法__format__()
方法__hash__()
方法__bool__()
方法__bytes__()
方法comparison
运算符方法__del__()
方法__new__()
方法与不变对象__new__()
方法与元类
本章的代码文件可在中找到 https://git.io/fj2UE 。
Python 通常提供对象的两种字符串表示形式。这些与内置的repr()
、str()
和print()
功能以及string.format()
方法密切相关:
-
一般来说,对象的
str()
方法表示预期对人类更友好。它是通过对象的__str__()
方法构建的。 -
repr()
方法表示通常更具技术性,通常使用完整的 Python 表达式来重建对象。Python 文档(中的__repr__()
方法文档 https://docs.python.org/3/reference/datamodel.html?highlight=del#object.repr 声明如下:
" If at all possible, this should look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment). "
- 这是通过对象的
__repr__()
方法构建的。 print()
功能通常使用str()
来准备打印对象。- 字符串的
format()
方法也可以访问这些方法。当我们使用字符串格式行{x:d}
时,我们为x
对象的__format__()
方法提供了一个"d"
参数。当我们使用{x!r}
或{x!s}
格式时,我们分别请求__repr__()
或__str__()
。
让我们先看看默认实现。下面是一个简单的类层次结构:
class Card:
def __init__ ( self , rank: str , suit: str ) -> None :
self .suit = suit
self .rank = rank
self .hard, self .soft = self ._points()
def _points( self ) -> Tuple[ int , int ]:
return int ( self .rank), int ( self .rank)
class AceCard(Card):
def _points(self) -> Tuple[int, int]:
return 1 , 11
class FaceCard(Card):
def _points(self) -> Tuple[int, int]:
return 10 , 10
我们定义了三个类,每个类中有四个属性。
以下是与这些类之一的对象的交互:
>>> x = Card('2','♠')
>>> str(x)
'<__main__.Card object at 0x1078d4518>'
>>> repr(x)
'<__main__.Card object at 0x1078d4518>'
>>> print(x) <__main__.Card object at 0x1078d4518>
我们可以从这个输出中看到,__str__()
和__repr__()
的默认实现信息量不大。
有两个广泛的设计案例,我们考虑当重写 AutoT0 和 T1 时:
- 简单对象:简单对象不包含其他对象的集合,通常不涉及非常复杂的格式。
- 集合对象:包含集合的对象涉及更复杂的格式。
如前所述,__str__()
和__repr__()
的输出信息量不大。我们几乎总是需要覆盖它们。以下是在不涉及收集时覆盖__str__()
和__repr__()
的方法。这些方法属于Card
类,之前定义如下:
def __repr__ ( self ) -> str :
return f"{self.__class__. __name__ }(suit={self.suit!r}, rank={self.rank!r})"
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
这两种方法依赖于f
-字符串将实例中的值插值到字符串模板中。在__repr__()
方法中,使用类名、suit 和 rank 创建可用于重建对象的字符串。在__str__()
方法中,军衔和军服以易于阅读的形式显示。
模板字符串使用两种格式规范:
{self.__class__.__name__}
格式规范也可以写成{self.__class__.__name__!s}
,以包含明确的!s
格式规范。这是默认格式,意味着使用str()
获取对象的字符串表示形式。{self.suit!r}
和{self.rank!r}
规范都使用!r
格式使用repr()
函数来获取属性值的表示。
当涉及到一个集合时,我们需要格式化集合中的每个项目,以及这些项目的整体容器。以下是使用__str__()
和__repr__()
两种方法的简单集合:
class Hand:
def __init__ ( self , dealer_card: Card, *cards: Card) -> None :
self .dealer_card = dealer_card
self .cards = list (cards)
def __str__ ( self ) -> str :
return ", " .join( map ( str , self .cards))
def __repr__ ( self ) -> str :
cards_text = ', ' .join( map ( repr , self .cards))
return f"{self.__class__. __name__ }({self.dealer_card!r}, {cards_text})"
__str__()
方法具有将str()
应用于集合中项目的典型配方,如下所示:
- 将
str()
映射到集合中的每个项目。这将在结果字符串值上创建迭代器。 - 使用
", ".join()
将所有项目字符串合并为一个长字符串。
__repr__()
方法是将repr()
应用于集合中项目的类似方法,如下所示:
- 将
repr()
映射到集合中的每个项目。这将在结果字符串值上创建迭代器。 - 使用
", ".join()
合并所有项目字符串。 - 使用
f"{self.__class__.__name__}({self.dealer_card!r}, {cards_text})"
组合类名、经销商卡和项目值的长字符串。此格式使用!r
格式,以确保dealer_card
属性也使用repr()
转换。
在构建复杂对象的表示时,__str__()
使用str()
和__repr__()
使用repr()
非常重要。这种简单的一致性保证了具有多层嵌套的非常复杂对象的结果将具有一致的字符串值
__format__()
方法用于f
-字符串、str.format()
方法以及format()
内置函数。这三个接口都用于创建给定对象的可呈现字符串版本。
以下是将参数呈现给someobject
对象的__format__()
方法的两种方式:
someobject.__format__("")
:当应用程序使用诸如f"{someobject}"
之类的字符串、诸如format(someobject)
之类的函数或诸如"{0}".format(someobject)
之类的字符串格式化方法时,会发生这种情况。在这些情况下,格式规范中没有:
,因此提供了一个默认的零长度字符串作为参数值。这将生成默认格式。someobject.__format__(spec)
:当应用程序使用一个字符串(如f"{someobject:spec}"
、一个函数(如format(someobject, spec)
)或与"{0:spec}".format(someobject)
字符串方法等效的东西时,就会发生这种情况。
请注意,带有!r
的f
-字符串f"{item!r}"
或带有!r
的"{0!r}".format(item)
格式方法不使用对象的__format__()
方法。!
之后的部分是转换规范。
!r
的转换规范使用repr()
函数,通常通过对象的__repr__()
方法实现。类似地,!s
的转换规范使用str()
函数,该函数通过__str__()
方法实现。!a
转换规范使用ascii()
功能。ascii()
函数通常依赖__repr__()
方法来提供底层表示
规范为""
时,合理的实现为return str(self)
。这在对象的各种字符串表示形式之间提供了明显的一致性。
作为__format__()
参数值提供的格式规范将是原始格式字符串中":"
之后的所有文本。当我们写入f"{value:06.4f}"
时,06.4f
是应用于要格式化的字符串中item
值的格式规范。
Python 语言参考的第 2.4.3节定义了格式化字符串(f
-字符串)迷你语言,每个格式规范有以下语法:
[[fill]align][sign][#][0][width][grouping_option][.precision][type]
我们可以用一个正则表达式(RE)解析这些潜在复杂的标准规范,如下面的代码片段所示:
re.compile(
r"(?P<fill_align>.?[\<\>=\^])?"
r"(?P<sign>[-+ ])?"
r"(?P<alt>#)?"
r"(?P<padding>0)?"
r"(?P<width>\d*)"
r"(?P<grouping_option>,)?"
r"(?P<precision>\.\d*)?"
r"(?P<type>[bcdeEfFgGnosxX%])?")
此 RE 将规范分为八组。第一组将具有原始规范中的fill
和alignment
字段。我们可以使用这些组来计算我们定义的类属性的格式。
在某些情况下,这种非常通用的格式规范迷你语言可能不适用于我们定义的类。这就需要定义一种格式规范迷你语言,并用定制的__format__()
方法对其进行处理。
作为一个例子,这里有一个简单的语言,它使用%r
字符向我们显示等级,%s
字符向我们显示Card
类实例的套装。结果字符串中的%%
字符变为%
。所有其他字符按字面重复。
我们可以使用格式扩展我们的Card
类,如下面的代码片段所示:
def __format__ ( self , format_spec: str) -> str :
if format_spec == "" :
return str ( self )
rs = (
format_spec.replace( "%r" , self .rank)
.replace( "%s" , self .suit)
.replace( "%%" , "%" )
)
return rs
此定义检查格式规范。如果没有规范,则使用str()
功能。如果提供了规范,则会进行一系列替换,将秩、西服和任何%
字符折叠成格式规范,将其转换成输出字符串。
这使我们可以按如下方式格式化卡片:
print( "Dealer Has {0:%r of %s}".format( hand.dealer_card) )
格式规范("%r of %s"
作为format
参数传递给我们的__format__()
方法。使用它,我们能够为我们定义的类的对象的表示提供一致的接口。
或者,我们可以定义如下:
default_format = "some specification"
def __str__(self) -> str:
return self.__format__(self.default_format)
def __format__(self, format_spec: str) -> str:
if format_spec == "":
format_spec = self.default_format
# process using format_spec.
这样做的好处是将所有字符串表示放在__format__()
方法中,而不是将它们分散在__format__()
和__str__()
之间。这确实有一个缺点,因为我们并不总是需要实现__format__()
,但我们几乎总是需要实现__str__()
。
string.format()
方法可以处理{}
的嵌套实例,对格式规范本身进行简单的关键字替换。这个替换是为了创建传递给我们类的__format__()
方法的最终格式字符串。这种嵌套替换通过参数化其他通用规范简化了某些相对复杂的数值格式。
下面是一个例子,我们使width
在format
参数中易于更改:
width = 6
for hand, count in statistics.items():
print(f"{hand} {count:{width}d}")
我们已经定义了一个通用格式,f"{hand} {count:{width}d}"
,它需要一个width
参数才能将其变成最终的格式规范。在本例中,width
为6
,表示最终格式为f"{hand} {count:6d}"
。扩展格式字符串"6d"
将是提供给基础对象的__format__()
方法的规范。
格式化包含集合的复杂对象时,我们有两个格式化问题:如何格式化整个对象以及如何格式化集合中的项。例如,当我们看Hand
时,我们看到我们有一组单独的Cards
对象。我们希望让Hand
将一些格式细节委托给Hand
集合中的各个Card
实例。
以下是适用于Hand
的__format__()
方法:
def __format__ ( self , spec: str ) -> str :
if spec == "" :
return str ( self )
return ", " .join( f"{c:{spec}}" for c in self .cards)
spec
参数将用于Hand
集合中的每个Card
实例。f
-字符串f"{c:{spec}}"
使用嵌套格式规范技术将spec
字符串推入格式。这将创建一个最终格式,该格式将应用于每个Card
实例。
根据这种方法,我们可以格式化一个Hand
对象player_hand
,如下所示:
>>> d = Deck()
>>> h = Hand(d.pop(), d.pop(), d.pop())
>>> print("Player: {hand:%r%s}".format(hand=h)) Player: K♦, 9♥
print()
函数中的这个字符串使用了Hand
对象的format()
方法。这将%r%s
格式规范传递给Hand
对象的每个Card
实例,为手牌的每个卡片提供所需的格式。
内置的hash()
函数调用给定对象的__hash__()
方法。此哈希是一种将(可能复杂的)值减少为小整数值的计算。理想情况下,哈希反映源值的所有位。其他散列计算——通常用于加密目的——可以产生非常大的值。
Python 包含两个hash
库。加密质量散列函数位于hashlib
中。zlib
模块还具有两个高速哈希函数:adler32()
和crc32()
。对于最常见的情况,我们不使用任何这些库函数。它们只需要散列非常大、复杂的对象。
hash()
函数(以及相关的__hash__()
方法)用于创建用于处理集合的小整数键,例如set
、frozenset
和dict
。这些集合使用不可变对象的hash
值在集合中快速定位该对象。
不变性在这里很重要;我们会多次提到它。不可变对象不会更改其状态。例如,数字3
不能有意义地改变状态。总是3
。类似地,许多复杂对象可以具有不可变状态。Python 字符串是不可变的。然后可以将它们用作映射和集的键。
从对象继承的默认__hash__()
实现返回一个基于对象内部 ID 值的值。通过id()
功能可以看到该值,如下所示:
>>> x = object()
>>> hash(x)
280696151
>>> id(x)
4491138416
>>> id(x) / 16
280696151.0
本例中显示的id
值因系统而异。
由此可以看出,在作者的系统中,哈希值是对象的id//16
。此详细信息可能因平台而异。
重要的是内部 ID 和默认的__hash__()
方法之间的强相关性。这意味着默认行为是每个对象都是可散列且完全不同的,即使这些对象看起来具有相同的值。
如果我们想将具有相同值的不同对象合并到一个可散列对象中,则需要修改此选项。我们将在下一节中查看一个示例,其中我们希望将单个Card
实例的两个实例视为相同的对象。
不是每个对象都应该提供哈希值。具体地说,如果我们正在创建一个有状态、可变的对象类,那么该类永远不会返回哈希值。不应该有__hash__()
方法的实现。
另一方面,不可变对象可能会明智地返回哈希值,以便该对象可以用作字典中的键或集合的成员。在这种情况下,哈希值需要与相等性测试的工作方式并行。拥有声称相等且具有不同散列值的对象是不好的。具有相同散列且实际上不相等的反向对象是可接受的,也是预期的。几个不同的对象可能恰好具有重叠的哈希值。
平等性比较分为三个层次:
- 相同的散列值:这意味着两个对象可以相等。散列值为我们提供了可能相等的快速检查。如果哈希值不同,则两个对象不可能相等,也不可能是同一个对象。
- 比较为相等:这意味着哈希值也必须相等。这是
==
运算符的定义。这些对象可能是同一个对象。 - 相同 ID 值:表示它们是同一个对象。它们也会进行相等的比较,并具有相同的哈希值。这是
is
运算符的定义。
散列的基本定律(FLH分为两部分:
- 比较为相等的对象具有相同的哈希值。
- 具有相同散列值的对象实际上可能是不同的,并且不能作为相等的对象进行比较。
我们可以把散列比较看作是平等测试的第一步。这是一个非常快速的比较,可以确定是否需要后续的相等性检查
__eq__()
方法与哈希密切相关,我们也将在比较运算符一节中介绍该方法。这可能会导致对象之间的逐场比较缓慢。
下面是两个具有相同哈希值的不同数值的人为示例:
>>> v1 = 123_456_789
>>> v2 = 2_305_843_009_337_150_740
>>> hash(v1)
123456789
>>> hash(v2)
123456789
>>> v2 == v1
False
请注意,v1
整数等于123,456,789
,其散列值等于自身。这是散列模之前的典型整数。v2
整数具有相同的散列,但实际值不同。
此哈希冲突是预期的。这是创建集合或字典时已知处理开销的一部分。将有不相等的对象减少为恰好相等的散列值
通过__eq__()
和__hash__()
方法函数定义相等性测试和散列值有三个用例:
- 不可变对象:这些是类型为元组、命名元组和冻结集的无状态对象,无法更新。我们有两个选择:
- 既不定义
__hash__()
也不定义__eq__()
。这意味着什么也不做,并且使用继承的定义。在这种情况下,__hash__()
返回对象 ID 值的一个普通函数,__eq__()
比较 ID 值 - 同时定义
__hash__()
和__eq__()
。请注意,我们需要为不可变对象定义这两个。
- 既不定义
- 可变对象:这些是有状态的对象,可以在内部修改。我们有一种设计选择:
- 定义
__eq__()
但将__hash__
设置为None
。这些不能用作dict
键或sets
中的项目。
- 定义
当应用程序需要两个不同的对象进行相等比较时,不可变对象的默认行为是不可取的。例如,我们可能希望Card(1, Clubs)
的两个实例测试为相等,并计算相同的散列;这在默认情况下不会发生。要使其工作,我们需要重写__hash__()
和__eq__()
方法。
请注意,还有一个可能的组合:定义__hash__()
,但使用__eq__()
的默认定义。这只是浪费代码,因为默认的__eq__()
方法与is
操作符相同。使用默认的__hash__()
方法可能需要为相同的行为编写更少的代码。
我们将详细研究这三种情况。
让我们看看默认定义是如何操作的。以下是一个简单的class
层次结构,它使用__hash__()
和__eq__()
的默认定义:
class Card:
insure = False
def __init__ ( self , rank: str , suit: "Suit" , hard: int , soft: int ) -> None :
self .rank = rank
self .suit = suit
self .hard = hard
self .soft = soft
def __repr__ ( self ) -> str :
return f"{self.__class__. __name__ }(suit={self.suit!r}, rank={self.rank!r})"
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
class NumberCard(Card):
def __init__ ( self , rank: int , suit: "Suit" ) -> None :
super (). __init__ ( str (rank), suit, rank, rank)
class AceCard(Card):
insure = True
def __init__ ( self , rank: int , suit: "Suit" ) -> None :
super (). __init__ ( "A" , suit, 1 , 11 )
class FaceCard(Card):
def __init__ ( self , rank: int , suit: "Suit" ) -> None :
rank_str = { 11 : "J" , 12 : "Q" , 13 : "K" }[rank]
super (). __init__ (rank_str, suit, 10 , 10 )
这是哲学上不变对象的class
层次结构。我们没有注意实现防止属性更新的特殊方法。我们将在下一章中介绍属性访问。以下是Suit
值枚举类的定义:
from enum import Enum
class Suit( str , Enum):
Club = " \N{BLACK CLUB SUIT} "
Diamond = " \N{BLACK DIAMOND SUIT} "
Heart = " \N{BLACK HEART SUIT} "
Spade = " \N{BLACK SPADE SUIT} "
让我们看看当我们使用这个class
层次结构时会发生什么:
>>> c1 = AceCard(1, Suit.Club) >>> c2 = AceCard(1, Suit.Club)
我们定义了两个看似相同的Card
实例的实例。我们可以检查id()
值,如下代码段所示:
>>> id(c1), id(c2) (4492886928, 4492887208)
他们有不同的id()
编号;这意味着它们是不同的对象。这符合我们的期望。
我们可以使用is
操作符检查它们是否相同,如下面的代码段所示:
>>> c1 is c2
False
is
测试基于id()
编号;它向我们表明,它们确实是独立的对象。
我们可以看到它们的散列值彼此不同:
>>> hash(c1), hash(c2) (-9223372036575077572, 279698247)
这些散列值直接来自id()
值。这是我们对继承方法的期望。在这个实现中,我们可以从id()
函数中计算散列,如下代码片段所示:
>>> id(c1) / 16
268911077.0
>>> id(c2) / 16
268911061.0
由于散列值不同,因此它们不能作为相等值进行比较。虽然这符合 hash 和 equality 的定义,但它违反了我们对此类实例的期望。
我们创建了具有相同属性的两个对象。以下是平等性检查:
>>> c1 == c2
False
即使对象具有相同的属性值,它们也不会进行相等的比较。在某些应用程序中,这可能不太好。例如,当累积经销商卡的统计计数时,我们不希望一张卡有六个计数,因为模拟使用了六层鞋。
我们可以看到,这些是适当的不可变对象。以下示例显示可以将这些对象放入一个集合中:
>>> set([c1, c2])
{AceCard(suit=<Suit.Club: '♣'>, rank='A'), AceCard(suit=<Suit.Club: '♣'>, rank='A')}
这是标准库参考文档中记录的行为。默认情况下,我们将获得一个基于对象 ID 的__hash__()
方法,这样每个实例看起来都是唯一的。然而,这并不总是我们想要的。
以下是一个简单的class
层次结构,为我们提供了__hash__()
和__eq__()
的定义:
import sys
class Card2:
insure = False
def __init__ ( self , rank: str , suit: "Suit" , hard: int , soft: int ) -> None :
self .rank = rank
self .suit = suit
self .hard = hard
self .soft = soft
def __repr__ ( self ) -> str :
return f"{self.__class__. __name__ }(suit={self.suit!r}, rank={self.rank!r})"
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
def __eq__(self, other: Any) -> bool:
return (
self.suit == cast(Card2, other).suit
and self.rank == cast(Card2, other).rank
)
def __hash__ ( self ) -> int :
return (hash(self.suit) + 4*hash(self.rank)) % sys.hash_info.modulus
class AceCard2(Card2):
insure = True
def __init__ ( self , rank: int , suit: "Suit" ) -> None :
super (). __init__ ( "A" , suit, 1 , 11 )
这个对象原则上是不变的。不可变实例没有正式的机制。我们将在第 4 章、属性访问、属性和描述符中了解如何防止属性值更改。
另外,请注意,前面的代码省略了与前面的示例相比没有显著变化的两个子类,FaceCard
和NumberCard
。
__eq__()
方法有一个类型提示,表示它将比较任何类的对象并返回bool
结果。实现使用一个cast()
函数向mypy提供一个提示,other
的值将始终是Card2
的实例,或者可能引发运行时类型错误。cast()
函数是 mypy 类型暗示的一部分,没有任何运行时效果。该函数比较两个基本值:suit
和rank
。它不需要比较硬值和软值;它们来源于rank
。
二十一点的规则使得这个定义有点可疑。在21 点中,西装其实并不重要。我们应该仅仅比较等级吗?我们是否应该定义一个只比较排名的附加方法?或者,我们应该依靠应用程序来正确比较排名吗?这些问题没有最好的答案;这些是潜在的设计权衡。
__hash__()
函数根据两个基本属性计算唯一的值模式。此计算基于排名和西装的哈希值。秩将占据值的最高有效位,而 suit 将是最低有效位。这与卡片的排列方式类似,等级比花色更重要。散列值必须使用sys.hash_info.modulus
值作为模数约束进行计算。
让我们看看这些类的对象是如何工作的。我们希望它们能平等地进行比较,并在集合和字典中表现良好。以下是两个对象:
>>> c1 = AceCard2(1, '♣')
>>> c2 = AceCard2(1, '♣')
我们定义了两个看起来是同一张卡的实例。我们可以检查 ID 值以确保它们是不同的对象:
>>> id(c1), id(c2)
(4302577040, 4302577296)
>>> c1 is c2
False
这些有不同的id()
编号。当我们使用is
操作符进行测试时,我们看到它们是不同的对象。到目前为止,这符合我们的期望。
让我们比较一下散列值:
>>> hash(c1), hash(c2)
(1259258073890, 1259258073890)
散列值是相同的。这意味着他们可以平等。
相等运算符向我们显示,它们正确地比较为相等:
>>> c1 == c2
True
因为这些对象产生一个散列值,所以我们可以将它们放入一个集合中,如下所示:
>>> set([c1, c2]) {AceCard2(suit=<Suit.Club: '♣'>, rank='A')}
由于这两个对象创建相同的散列值并测试为相等,因此它们似乎是对同一对象的两个引用。其中只有一个被保留在布景中。这满足了我们对复杂不可变对象的期望。我们必须覆盖这两种特殊方法,以获得一致、有意义的结果。
本例将继续使用Cards
类。易变卡的想法很奇怪,甚至可能是错误的。然而,我们只想对前面的示例应用一个小的调整。
下面是一个类层次结构,它为我们提供了适用于可变对象的__hash__()
和__eq__()
的定义。父类如下所示:
class Card3:
insure = False
def __init__ ( self , rank: str , suit: "Suit" , hard: int , soft: int ) -> None :
self .rank = rank
self .suit = suit
self .hard = hard
self .soft = soft
def __repr__ ( self ) -> str :
return f"{self.__class__. __name__ }(suit={self.suit!r}, rank={self.rank!r})"
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
def __eq__(self, other: Any) -> bool:
return (
self.suit == cast(Card3, other).suit
and self.rank == cast(Card3, other).rank
)
Card3
的一个子类如下图所示:
class AceCard3(Card3):
insure = True
def __init__ ( self , rank: int , suit: "Suit" ) -> None :
super (). __init__ ( "A" , suit, 1 , 11 )
让我们看看这些类的对象是如何工作的。我们希望它们能平等地进行比较,但在集合或字典中根本不起作用。我们将创建两个对象,如下所示:
>>> c1 = AceCard3(1, '♣')
>>> c2 = AceCard3(1, '♣')
我们已经定义了两个看起来是同一张卡的实例。
我们将查看它们的 ID 值,以确保它们确实是不同的:
>>> id(c1), id(c2)
(4302577040, 4302577296)
这并不奇怪。现在,我们来看看是否可以得到散列值:
>>> hash(c1), hash(c2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'
Card3
对象不能散列。它们不会为hash()
函数提供值。这是预期的行为。但是,我们可以执行相等比较,如以下代码段所示:
>>> c1 == c2
True
相等性测试工作正常,允许我们比较卡片。它们不能插入到集合中,也不能用作字典中的键。
下面是当我们尝试将它们放入一个集合时发生的情况:
>>> set([c1, c2])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'
我们尝试这样做时会得到一个适当的异常。
显然,这并不是对现实生活中不可改变的事物(如卡片)的正确定义。这种定义风格更适合于有状态对象,例如手的内容总是在变化的Hand
。在下一节中,我们将为您提供第二个有状态对象示例。
如果我们想对特定的Hand
实例进行统计分析,我们可能需要创建一个字典,将Hand
实例映射到一个计数。我们不能使用可变的Hand
类作为映射中的键。但是,我们可以将set
和frozenset
的设计并行,并创建两个类:Hand
和FrozenHand
。这允许我们通过创建FrozenHand
来冻结一个Hand
实例;冻结版本是不可变的,可以用作字典中的键
以下是一个简单的Hand
定义:
class Hand:
def __init__ ( self , dealer_card: Card2, *cards: Card2) -> None :
self .dealer_card = dealer_card
self .cards = list (cards)
def __str__ ( self ) -> str :
return ", " .join( map ( str , self .cards))
def __repr__ ( self ) -> str :
cards_text = ", " .join( map ( repr , self .cards))
return f"{self.__class__. __name__ }({self.dealer_card!r}, {cards_text})"
def __format__ ( self , spec: str ) -> str :
if spec == "" :
return str ( self )
return ", " .join( f"{c:{spec}}" for c in self .cards)
def __eq__ ( self , other: Any) -> bool :
if isinstance (other, int ):
return self .total() == cast( int , other)
try :
return (
self .cards == cast(Hand, other).cards
and self .dealer_card == cast(Hand, other).dealer_card
)
except AttributeError :
return NotImplemented
这是一个可变的对象;它不计算散列值,并且不能在集合或字典键中使用。它确实有一个适当的平等测试来比较两只手。与前面的示例一样,__eq__()
方法的参数具有类型提示Any
,并且使用不做任何事情cast()
函数告诉 mypy 程序参数值将始终是Hand
的实例。以下是Hand
的冻结版本:
import sys
class FrozenHand(Hand):
def __init__ ( self , *args, **kw) -> None :
if len (args) == 1 and isinstance (args[ 0 ], Hand):
# Clone a hand
other = cast(Hand, args[ 0 ])
self .dealer_card = other.dealer_card
self .cards = other.cards
else :
# Build a fresh Hand from Card instances.
super (). __init__ (*args, **kw)
def __hash__ ( self ) -> int :
return sum ( hash (c) for c in self .cards) % sys.hash_info.modulus
冻结版本有一个构造函数,它将从另一个Hand
类构建一个Hand
类。它定义了一个对卡的哈希值求和的__hash__()
方法,该方法限制为sys.hash_info.modulus
值。在大多数情况下,这种基于模的计算对于计算复合对象的散列非常有效。现在,我们可以将这些类用于操作,例如以下代码段:
from collections import defaultdict
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
类的实例,我们可以计算散列并将其用作字典中的键。这使得创建一个defaultdict
来收集实际发牌的每一手牌的计数变得很容易。
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)
这将布尔函数委托给内部self._cards
集合。
如果我们要扩展一个列表,我们可能会有如下内容:
def __bool__(self):
return super().__bool__(self)
这将委托给__bool__()
函数的超类定义。
在这两种情况下,我们都专门委托布尔测试。在 wrap 案例中,我们将委托给集合。在 extend 的例子中,我们将委托给超类。无论是换行还是扩展,空集合都将是False
。这将为我们提供一种方法来查看Deck
对象是否已完全处理且为空。
我们可以按以下代码段所示执行此操作:
d = Deck()
while d:
card = d.pop()
# process the card
当牌组耗尽时,此循环将处理所有牌,而不会得到IndexError
异常。
在相对较少的情况下,需要将对象转换为字节。字节表示用于对象的序列化,以实现持久存储或传输。我们将在第 10 章、序列化和保存—JSON、YAML、Pickle、CSV 和 XML到第 14 章、配置文件和持久化中详细介绍这一点。
在最常见的情况下,应用程序将创建字符串表示,Python IO 类的内置编码功能可用于将字符串转换为字节。这几乎适用于所有情况。主要的例外是当我们定义一种新的字符串时。在这种情况下,我们需要定义该字符串的编码。
bytes()
函数执行多种操作,具体取决于参数:
bytes(integer)
:返回一个具有给定数量0x00
值的不可变字节对象。bytes(string)
:将给定字符串编码为字节。编码和错误处理的附加参数将定义编码过程的细节。bytes(something)
:这将调用something.__bytes__()
来创建一个字节对象。此处不使用错误参数的编码。
基础object
类没有定义__bytes__()
。这意味着我们的类在默认情况下不提供__bytes__()
方法。
在某些特殊情况下,我们可能需要在将对象写入文件之前将其直接编码为字节。使用字符串并允许str
类型为我们生成字节通常更简单。在处理字节时,需要注意的是,没有简单的方法可以解码文件或接口中的字节。内置的bytes
类将只解码字符串,而不是我们独特的新对象。这意味着我们需要解析从字节解码的字符串。或者,我们可能需要使用struct
模块显式解析字节,并根据解析的值创建我们的唯一对象。
我们将研究如何将Card2
实例编码和解码为字节。因为只有 52 个卡值,所以每个卡可以打包成一个字节。但是,我们选择使用一个字符来表示suit
和一个字符来表示rank
。此外,我们需要正确地重构Card2
的子类,因此我们必须对以下几项进行编码:
Card2
的子类(AceCard2
、NumberCard2
和FaceCard2
)- 子类的参数定义了
__init__()
方法。
请注意,我们的一些替代__init__()
方法会将数值列转换为字符串,从而丢失原始数值。为了实现可逆字节编码,我们需要重建这个原始的数字秩值。
以下是__bytes__()
的实现,返回Card2
子类名称rank
和suit
的utf-8
编码:
def __bytes__ ( self ) -> bytes :
class_code = self .__class__. __name__ [ 0 ]
rank_number_str = { "A" : "1" , "J" : "11" , "Q" : "12" , "K" : "13" }.get(
self .rank, self .rank
)
string = f"({' '.join([class_code, rank_number_str, self.suit])})"
return bytes (string, encoding = "utf-8" )
这是通过创建Card2
对象的字符串表示来实现的。表示法使用()
对象围绕三个空格分隔的值:表示类的代码、表示等级的字符串和西装。然后将该字符串编码为字节
以下代码段显示字节表示的外观:
>>> c1 = AceCard2(1, Suit.Club)
>>> bytes(c1)
b'(A 1 \xe2\x99\xa3 )'
当我们得到一堆字节时,我们可以从字节中解码字符串,然后将字符串解析为一个新的Card2
对象。下面是一个可以用来从字节创建Card2
对象的方法:
def card_from_bytes(buffer: bytes ) -> Card2:
string = buffer.decode( "utf8" )
try :
if not (string[ 0 ] == "(" and string[- 1 ] == ")" ):
raise ValueError
code, rank_number, suit_value = string[ 1 :- 1 ].split()
if int (rank_number) not in range ( 1 , 14 ):
raise ValueError
class_ = { "A" : AceCard2, "N" : NumberCard2, "F" : FaceCard2}[code]
return class_( int (rank_number), Suit(suit_value))
except ( IndexError , KeyError , ValueError ) as ex :
raise ValueError ( f"{buffer!r} isn't a Card2 instance" )
在前面的代码中,我们将字节解码为字符串。我们检查了字符串是否需要()
。然后,我们使用string[1:-1].split()
将字符串解析为三个单独的值。根据这些值,我们将秩转换为有效范围的整数,定位类,并构建原始的Card2
对象。
我们可以从字节流重构一个Card2
对象,如下所示:
>>> data = b'(N 5 \ xe2 \ x99 \ xa5)'
>>> c2 = card_from_bytes(data)
>>> c2 NumberCard2(suit=<Suit.Heart: '♥'>, rank='5')
重要的是要注意,外部字节表示通常很难设计。在所有情况下,字节都是对象状态的表示。Python 已经有了许多表示,这些表示可以很好地用于各种类定义。
使用pickle
或json
模块通常比发明对象的低级字节表示更好。这将是第 10 章、序列化和保存的主题—JSON、YAML、Pickle、CSV 和 XML。
Python 有六个比较运算符。这些操作符有特殊的方法实现。根据文件,测绘工作如下:
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)
实现。
在第 8 章创建数字中,我们将再次回到比较运算符。
这里还有一条关于实际实现哪些操作符的附加规则。这些规则基于这样一种思想:操作符左侧的对象类定义了所需的特殊方法。如果没有,Python 可以通过改变顺序并考虑操作符右侧的对象来尝试另一种操作。
Here are the two basic rules
First, the operand on the left-hand side of the operator is checked for an implementation: A<B
means A.__lt__(B)
.
Second, the operand on the right-hand side of the operator is checked for a reversed implementation: A<B
means B.__gt__(A)
.
The rare exception to this occurs when the right-hand operand is a subclass of the left-hand operand; then, the right-hand operand is checked first to allow a subclass to override a superclass.
我们可以通过定义一个只定义了一个运算符的类,然后将其用于其他操作来了解其工作原理。
下面是我们可以使用的分部类:
class BlackJackCard_p:
def __init__ ( self , rank: int , suit: Suit) -> None :
self .rank = rank
self .suit = suit
def __lt__ ( self , other: Any) -> bool :
print ( f"Compare {self} < {other}" )
return self .rank < cast(BlackJackCard_p, other).rank
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
这遵循了二十一点比较规则,在这一规则中,套装并不重要,卡片只根据等级进行比较。除了一个比较方法外,我们省略了所有的比较方法,以了解在缺少运算符时 Python 将如何后退。这个类允许我们进行<
比较。有趣的是,Python 还可以通过切换参数顺序来执行>
比较。换句话说,。这是镜面反射定律;我们将在第 8 章创造数字中再次看到。
当我们尝试评估不同的比较操作时,就会看到这一点。我们将创建两个BlackJackCard_p
实例,并以各种方式进行比较,如下面的代码片段所示:
*>>> two = BlackJackCard_p(2, Suit.Spade)* >>> three = BlackJackCard_p(3, Suit.Spade)
>>> two < three
Compare 2♠ < 3♠
True
>>> two > three
Compare 3♠ < 2♠
False
>>> two == three
False
此示例显示,使用<
运算符的比较是通过定义的__lt__()
方法实现的,正如预期的那样。使用>
运算符时,也会使用可用的__lt__()
方法,但操作数会反转。
当我们尝试使用诸如<=
之类的运算符时会发生什么情况?这显示异常:
>>> two <= three
Traceback (most recent call last):
File "/Users/slott/miniconda3/envs/py37/lib/python3.7/doctest.py", line 1329, in __run
compileflags, 1), test.globs)
File "<doctest __main__.__test__.test_blackjackcard_partial[5]>", line 1, in <module>
print("{0} <= {1} :: {2!r}".format(two, three, two <= three)) # doctest: +IGNORE_EXCEPTION_DETAIL
TypeError: '<=' not supported between instances of 'BlackJackCard_p' and 'BlackJackCard_p'
由此,我们可以看到two < three
映射到two.__lt__(three)
的位置。
但是two > three
没有定义__gt__()
方法;Python 使用three.__lt__(two)
作为后备计划。
默认情况下,__eq__()
方法继承自object
。您会记得,默认实现会比较对象 ID,并且所有唯一对象都会比较为不相等。对象参与==
测试如下:
>>> two_c = BlackJackCard_p(2, Suit.Club)
>>> two_c == BlackJackCard_p(2, Suit.Club) False
我们可以看到,结果并不是我们所期望的那样。我们通常需要覆盖__eq__()
的默认实现。
运算符之间也没有逻辑连接。从数学上讲,我们可以从两个例子中得出所有必要的比较。Python 不会自动执行此操作。相反,Python 默认处理以下四个简单反射对:
这意味着我们必须至少从四对中各提供一对。例如,我们可以提供__eq__()
、__ne__()
、__lt__()
和__le__()
。
@functools.total_ordering
装饰器可以帮助克服默认限制。这位装饰师仅从__eq__()
和其中一个__lt__()
、__le__()
、__gt__()
或__ge__()
中推断出其余的比较。这提供了所有必要的比较。我们将在第 8 章创造数字中重新讨论这一点。
定义比较运算符时有两个注意事项:
- 最明显的问题是如何比较同一类的两个对象。
- 不太明显的问题是如何比较不同类的对象。
对于具有多个属性的类,在查看比较运算符时,我们通常会产生深刻的歧义。可能不太清楚哪些可用属性参与了比较。
考虑一下谦逊的扑克牌(再次!)。像card1 == card2
这样的表达式显然是用来比较rank
和suit
,对吗?这总是真的吗?毕竟,suit
在二十一点等游戏中并不重要。
如果我们想决定一个Hand
对象是否可以被拆分,我们必须决定拆分操作是否有效。在二十一点中,只有当两张牌的等级相同时,一手牌才能拆分。然后,我们为平等性测试选择的实现将改变我们如何实现分割手的规则。
这导致了一些替代方案。在一种情况下,等级的使用是隐含的;另一个要求它是明确的。以下是排名比较的第一个代码段:
if hand.cards[0] == hand.cards[1]
以下是排名比较的第二个代码段:
if hand.cards[0].rank == hand.cards[1].rank
虽然一个比较短,但简洁并不总是最好的。如果我们定义相等,只考虑 Enter T0T,我们可能会遇到创建单元测试的麻烦。如果我们只使用秩,那么当单元测试应该集中在完全正确的卡片上时,assert expectedCard == actualCard
将容忍各种各样的卡片。
像card1 <= 7
这样的表达式显然是为了比较rank
。排序运算符的语义是否应该与相等性测试稍有不同?
还有更多的权衡问题来自于仅排名的比较。如果此属性不用于排序比较,我们如何通过suit
排序卡片?
此外,相等性检查必须与哈希计算并行。如果我们在散列中包含了多个属性,那么我们还需要在相等比较中包含它们。在这种情况下,卡之间的相等(和不相等)必须是完整的Card
比较,因为我们正在散列Card
值以包括rank
和suit
。
然而,Card
之间的排序比较只能是rank
。类似地,与整数的比较只能是rank
。对于检测分割的特殊情况,可以使用hand.cards[0].rank == hand.cards[1].rank
,因为它明确说明了有效分割的规则。
我们将通过查看更完整的BlackJackCard
类来查看简单的同类比较:
class BlackJackCard:
def __init__ ( self , rank: int , suit: Suit, hard: int , soft: int ) -> None :
self .rank = rank
self .suit = suit
self .hard = hard
self .soft = soft
def __lt__ ( self , other: Any) -> bool :
if not isinstance (other, BlackJackCard):
return NotImplemented
return self .rank < other.rank
def __le__ ( self , other: Any) -> bool :
try :
return self .rank <= cast(BlackJackCard, other).rank
except AttributeError :
return NotImplemented
def __gt__ ( self , other: Any) -> bool :
if not isinstance (other, BlackJackCard):
return NotImplemented
return self .rank > other.rank
def __ge__ ( self , other: Any) -> bool :
try :
return self .rank >= cast(BlackJackCard, other).rank
except AttributeError :
return NotImplemented
def __eq__ ( self , other: Any) -> bool :
if not isinstance (other, BlackJackCard):
return NotImplemented
return ( self .rank == other.rank
and self .suit == other.suit)
def __ne__ ( self , other: Any) -> bool :
if not isinstance (other, BlackJackCard):
return NotImplemented
return ( self .rank != other.rank
or self .suit != other.suit)
def __str__ ( self ) -> str :
return f"{self.rank}{self.suit}"
def __repr__ ( self ) -> str :
return ( f"{self.__class__. __name__ }"
f"(rank={self.rank!r}, suit={self.suit!r}, "
f"hard={self.hard!r}, soft={self.soft!r})" )
这个示例类定义了所有六个比较运算符。
各种比较方法使用两种类型检查:类和协议:
- 基于类的类型检查使用
isinstance()
检查对象的类成员资格。当检查失败时,该方法返回特殊的NotImplemented
值;这允许另一个操作数实现比较。isinstance()
检查还通知 mypy 表达式中命名的对象的类型约束。 - 基于协议的类型检查遵循鸭子类型原则。如果对象支持适当的协议,它将具有必要的属性。这体现在
__le__()
和__ge__()
方法的实现中。try:
块用于包装尝试,并在对象中协议不可用时提供有用的NotImplemented
值。在这种情况下,cast()
函数用于通知 mypy 在运行时仅使用具有预期类协议的对象
检查对给定协议的支持而不是类中的成员资格有一个很小的概念优势:它避免了不必要的过度约束操作。完全有可能有人想在卡片上发明一种变体,该变体遵循BlackJackCard
协议,但未定义为BlackjackCard
的适当子类。使用isinstance()
检查可能会阻止其他有效类正常工作。
专注于协议的try:
块可能允许碰巧具有rank
属性的类工作。这种情况转化为难以解决的问题的风险为零,因为该类可能会在该应用程序中使用的其他任何地方失败。另外,谁将Card
的实例与恰好具有排名顺序属性的金融建模应用程序中的类进行比较?
在以后的示例中,我们将重点讨论使用try:
块进行基于协议的比较。这往往提供更多的灵活性。如果不需要灵活性,可以使用isinstance()
检查。
在我们的示例中,比较使用cast(BlackJackCard, other)
来坚持 mypyother
变量符合BlackjackCard
协议。在许多情况下,一个复杂类可能有许多由各种 mixin 定义的协议,cast()
函数将关注基本 mixin,而不是整个类。
比较方法显式返回NotImplemented
以通知 Python 此运算符未针对此类数据实现。Python 将尝试反转参数顺序,以查看另一个操作数是否提供实现。如果找不到有效的运算符,则会引发TypeError
异常。
我们省略了三个子类定义和工厂函数card21()
。它们被留作练习。
我们也省略了组内比较;我们将把它留到下一节。通过这个类,我们可以成功地比较卡片。下面是我们创建和比较三张卡片的示例:
>>> two = card21(2, "♠")
>>> three = card21(3, "♠")
>>> two_c = card21(2, "♣")
给定这三个BlackJackCard
实例,我们可以执行许多比较,如下面的代码片段所示:
>>> f"{two} == {three} is {two == three}"
2♠ == 3♠ is False
>>> two.rank == two_c.rank
True
>>> f"{two} < {three} is {two < three}"
2♠ < 3♠ is 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
>>> 2 == two False
为什么这些会提供响应?当遇到NotImplemented
值时,Python 将反转操作数。在本例中,整数值2
定义了int.__eq__()
方法,该方法允许意外类的对象。
当两个类共享公共属性和方法时,它们是多态的。一个常见的例子是int
和float
类的对象。两者都有实现+
操作符的__add__()
方法。另一个例子是,大多数集合提供了一个__len__()
方法来实现len()
功能。根据实现细节,结果以不同的方式产生。
让我们定义Hand
以便它在Hand
的几个子类之间执行有意义的混合类比较。与同类比较一样,我们必须准确地确定要比较的内容。我们将研究以下三种情况:
Hand
实例之间的相等比较应比较集合中的所有卡片。如果所有的牌都相等,那么两手牌是相等的。- 两个
Hand
实例之间的排序比较应该比较每个Hand
对象的一个属性。在二十一点的情况下,我们要比较手部点数的硬总计或软总计。 - 与
int
值的相等性比较应将Hand
对象的点与int
值进行比较。为了得到一个总数,我们必须在二十一点游戏中梳理硬总数和软总数的微妙之处。
当手中有一张王牌时,以下是两个候选总数:
- 软总计将 ace 视为 11
- 硬总计将 A 视为 1。如果软总计超过 21,则只有硬总计与游戏相关。
这意味着手牌的总数不是一张牌的简单总和。
我们必须先确定手中是否有王牌。根据这些信息,我们可以确定是否存在有效(小于或等于 21)软总计。否则,我们就只能依靠硬总数了。
非常差的多态性的一个症状是依赖isinstance()
来确定亚类成员。通常,这违反了封装和类设计的基本思想。一组好的多态子类定义应该与相同的方法签名完全等效。理想情况下,类定义也是不透明的;我们不需要查看类定义内部。一组糟糕的多态类使用大量的isinstance()
类测试。
在 Python 中,isinstance()
函数的某些用法是必需的。使用内置类时会出现这种情况。它的出现是因为我们不能将方法函数追溯添加到内置类中,并且可能不值得将它们子类化以添加多态性帮助器方法。
在一些特殊的方法中,有必要使用isinstance()
函数来实现跨多个对象类的操作,这些对象类没有简单的继承层次结构。在下一节中,我们将向您展示isinstance()
在不相关类中的惯用用法。
对于我们的cards
类层次结构,我们需要一个方法(或属性)来标识 ace,而不必使用isinstance()
。设计良好的方法或属性有助于使各种类具有适当的多态性。其思想是提供一个根据类而变化的变量属性值或方法实现。
我们有两种支持多态性的通用设计选择:
- 在所有具有不同值的相关类中定义类级别属性。
- 在所有具有不同行为的类中定义方法。
如果卡片的硬总计和软总计相差 10,则表示手中至少有一张 ace。我们不需要通过检查类成员身份来破坏封装。属性值提供了所需的所有信息。
当card.soft != card.hard
时,这是计算手的硬总计与软总计的足够信息。除了指示存在AceCard
之外,它还提供硬总计和软总计之间的精确偏移值
以下是total
方法的一个版本,该方法利用了软与硬delta
值:
def total( self ) -> int :
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
。对于大多数卡片,差异为零。对于 ace,差值将不为零。
给定硬总计和delta_soft
,我们可以确定返回哪个总计。如果hard+delta_soft
小于或等于21
,则该值为软总计。如果软总计大于 21,则恢复为硬总计。
给定Hand
对象的总计定义,我们可以有意义地定义Hand
实例之间的比较以及Hand
和int
之间的比较。为了确定我们正在进行哪种比较,我们被迫使用isinstance()
。
以下是Hand
的部分定义,并进行了比较。这是第一部分:
class Hand:
def __init__ ( self , dealer_card: Card2, *cards: Card2) -> None :
self .dealer_card = dealer_card
self .cards = list (cards)
def __str__ ( self ) -> str :
return ", " .join( map ( str , self .cards))
def __repr__ ( self ) -> str :
cards_text = ", " .join( map ( repr , self .cards))
return f"{self.__class__. __name__ }({self.dealer_card!r}, {cards_text})"
这里是第二部分,强调比较的方法:
def __eq__ ( self , other: Any) -> bool :
if isinstance (other, int ):
return self .total() == other
try :
return (
self .cards == cast(Hand, other).cards
and self .dealer_card == cast(Hand, other).dealer_card
)
except AttributeError :
return NotImplemented
def __lt__ ( self , other: Any) -> bool :
if isinstance (other, int ):
return self .total() < cast( int , other)
try :
return self .total() < cast(Hand, other).total()
except AttributeError :
return NotImplemented
def __le__ ( self , other: Any) -> bool :
if isinstance (other, int ):
return self .total() <= cast( int , other)
try :
return self .total() <= cast(Hand, other).total()
except AttributeError :
return NotImplemented
def total( self ) -> int :
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
我们定义了其中三个比较,而不是全部六个。Python 的默认行为可以填充缺少的操作。由于针对不同类型的特殊规则,我们将看到默认值并不完美。
为了与Hands
交互,我们需要几个Card
对象:
>>> two = card21(2, '♠')
>>> three = card21(3, '♠')
>>> two_c = card21(2, '♣')
>>> ace = card21(1, '♣')
>>> cards = [ace, two, two_c, three]
我们将使用这个卡片序列来查看两个不同的hand
实例。
第一个Hands
对象有一个不相关的经销商Card
对象和之前创建的四个Cards
的集合。Card
对象之一是 ace:
>>> h = Hand(card21(10,'♠'), *cards)
>>> print(h)
A♣, 2♠, 2♣, 3♠
>>> h.total()
18
18
分的总数是软总数,因为 ace 被视为有 11 分。这些卡的总积分是 8 分。
下面是第二个Hand
对象,它还有一个Card
对象:
>>> h2 = Hand(card21(10,'♠'), card21(5,'♠'), *cards)
>>> print(h2)
5♠, A♣, 2♠, 2♣, 3♠
>>> h2.total()
13
这只手总共有13
点。这是一个艰难的总数。软总数将超过 21,因此与游戏无关。
Hands
之间的比较非常好,如下面的代码片段所示:
>>> h < h2
False
>>> h > h2
True
这些比较意味着我们可以根据比较运算符对Hands
进行排序。我们也可以将Hands
与整数进行比较,如下所示:
>>> 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 不被迫尝试回退,与整数的比较就会起作用。h > 17
示例向我们展示了当没有__gt__()
方法时会发生什么。Python 检查反射的操作数,而整数17
也没有适合Hand
的__lt__()
方法。
我们可以添加必要的__gt__()
和__ge__()
函数,使Hand
能够正确处理整数。这两个比较的代码留给读者作为练习。
__del__()
方法有一个相当模糊的用例。
其目的是让对象有机会在从内存中删除对象之前进行任何清理或终结。通过上下文管理器对象和with
语句,可以更干净地处理这个用例。这是第 6 章使用可调用对象和上下文的主题。创建上下文比处理__del__()
和 Python 垃圾收集算法更容易预测。
如果 Python 对象具有相关的操作系统资源,__del__()
方法是最后一次将资源与 Python 应用程序彻底分离的机会。例如,隐藏打开的文件、挂载的设备或子进程的 Python 对象都可能受益于将资源作为__del__()
处理的一部分释放。
__del__()
方法在任何容易预测的时间都不会被调用。当对象被del
语句删除时,它并不总是被调用,当对象被删除时,它也不总是被调用,因为名称空间被删除。关于__del__()
方法的文档将情况描述为不稳定,并提供了关于异常处理的附加说明。在执行过程中发生的异常将被忽略,并将警告打印到sys.stderr
。请参见此处的警告:https://docs.python.org/3/reference/datamodel.html?highlight=del#object.del 。
出于这些原因,上下文管理器通常比实现__del__()
更可取。
对于 CPython 实现,对象具有引用计数。将对象指定给变量时,计数会增加,删除变量时,计数会减少。当引用计数为零时,对象不再需要,可以销毁。对于简单对象,将调用__del__()
并删除该对象。
对于对象之间具有循环引用的复杂对象,引用计数可能永远不会变为零,并且无法轻松调用__del__()
。下面是一个类,我们可以使用它来查看发生了什么:
class Noisy:
def __del__ ( self ) -> None :
print ( f"Removing { id (self)}" )
我们可以创建(并查看移除)这些对象,如下所示:
>>> x = Noisy()
>>> del x
Removing 4313946640
我们创建并删除了一个Noisy
对象,并且几乎立即看到了__del__()
方法的消息。这表示删除x
变量时,引用计数变为零。一旦变量消失,就不再有对Noisy
实例的引用,它也可以被清除。以下是一种常见情况,涉及经常创建的浅拷贝:
>>> ln = [Noisy(), Noisy()]
>>> ln2= ln[:]
>>> del ln
没有人回应这个del
声明。Noisy
对象的引用计数尚未归零;它们仍在某处被引用,如以下代码段所示:
>>> del ln2
Removing 4313920336
Removing 4313920208
ln2
变量是ln
列表的浅拷贝。Noisy
对象在两个列表中引用。在删除两个列表之前,Noisy
实例无法销毁,从而将引用计数减少到零。
有许多其他方法可以创建浅拷贝。以下是创建对象的浅拷贝的几种方法:
a = b = Noisy()
c = [Noisy()] * 2
这里的要点是,我们经常会被存在的对对象的引用数量所迷惑,因为 Python 中普遍存在浅拷贝。
以下是涉及循环的常见情况。一个类Parent
包含一个子类集合。每个Child
实例都包含对Parent
类的引用。我们将使用这两个类来检查循环引用:
class Parent:
def __init__ ( self , *children: 'Child' ) -> None :
for child in children:
child.parent = self
self .children = {c.id: c for c in children}
def __del__ ( self ) -> None :
print (
f"Removing {self.__class__. __name__ } { id (self):d}"
)
class Child:
def __init__ ( self , id: str ) -> None :
self .id = id
self .parent: Parent = cast(Parent, None )
def __del__ ( self ) -> None :
print (
f"Removing {self.__class__. __name__ } { id (self):d}"
)
一个Parent
实例在一个简单的dict
中有一个子对象集合。请注意,参数值*children
的类型提示为'Child'
。Child
类尚未定义。为了提供类型提示,mypy 将把字符串解析为模块中其他地方定义的类型。为了拥有正向引用或循环引用,我们必须使用字符串,而不是尚未定义的类型。
每个Child
实例都有一个对包含它的Parent
类的引用。当子对象插入父对象的内部集合时,将在初始化过程中创建引用。
我们将这两个类都设置为嘈杂的,以便在移除对象时可以看到。发生的情况如下:
>>> p = Parent(Child('a'), Child('b')) >>> del p
无法删除Parent
实例和两个初始Child
实例。它们都包含对彼此的引用。在del
语句之前,有三个对Parent
对象的引用。p
变量有一个引用。每个child
对象也有一个引用。当del
语句删除p
变量时,这会减少Parent
实例的引用计数。计数不是零,因此对象仍保留在内存中,无法使用。我们称之为内存泄漏。
我们可以创建一个无子女的Parent
实例,如下代码段所示:
>>> p_0 = Parent()
>>> id(p_0)
4313921744
>>> del p_0
Removing Parent 4313921744
如预期的那样,将立即删除此对象。
由于相互引用或循环引用,无法从内存中删除Parent
实例及其Child
实例列表。如果我们导入垃圾收集器接口gc
,我们可以收集和显示这些不可移动的对象。
我们将使用gc.collect()
方法收集所有具有__del__()
方法的不可移动对象,如以下代码片段所示:
>>> import gc
>>> gc.collect()
Removing Child 4536680176
Removing Child 4536680232
Removing Parent 4536679952
30
我们可以看到我们的Parent
对象是通过手动使用垃圾收集器清理的。collect()
函数定位不可访问的对象,识别任何循环引用,并强制删除它们。
请注意,我们不能通过将代码放入__del__()
方法来打破循环。在循环被破坏且参考计数已为零后,将__del__()
方法称为*。当我们有循环引用时,我们不能再依靠简单的 Python 引用计数来清除未使用对象的内存。我们必须明确地打破循环,或者使用允许垃圾收集的weakref
引用。*
如果我们需要循环引用,但也希望__del__()
能够很好地工作,我们可以使用弱引用。循环引用的一个常见用例是相互引用:具有子集合的父级,其中每个子级都有对父级的引用。如果Player
类有多个指针,Hand
对象包含对所属Player
类的弱引用可能会有所帮助。
默认对象引用可以称为强引用;然而,直接引用是一个更好的术语。Python 中的引用计数机制使用它们;它们不能被忽视。
考虑下面的陈述:
a = B()
a
变量直接引用所创建的B
类的对象。对B
实例的引用计数至少为一个,因为a
变量有一个引用。
弱引用涉及两个步骤来查找关联对象。弱引用将使用x.parent()
,将弱引用作为可调用对象调用,以跟踪实际的父对象。此两步过程允许引用计数或垃圾收集删除引用对象,使弱引用悬空。
weakref
模块定义了许多使用弱引用而不是强引用的集合。这允许我们创建字典,例如,允许对其他未使用的对象进行垃圾收集。
我们可以修改我们的Parent
和Child
类,以使用从Child
到Parent
的弱引用,允许更简单地销毁未使用的对象。
下面是一个修改过的类,它使用从Child
到Parent
的弱引用:
from weakref import ref
class Parent2:
def __init__ ( self , *children: 'Child2' ) -> None :
for child in children:
child.parent = ref( self )
self .children = {c.id: c for c in children}
def __del__ ( self ) -> None :
print (
f"Removing {self.__class__. __name__ } { id (self):d}"
)
class Child2:
def __init__ ( self , id: str ) -> None :
self .id = id
self .parent: ref[Parent2] = cast(ref[Parent2], None )
def __del__ ( self ) -> None :
print (
f"Removing {self.__class__. __name__ } { id (self):d}"
)
我们已经将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
类时,我们看到del
使引用计数变为零,并且对象立即被移除:
>>> p = Parent2(Child(), Child())
>>> del p
Removing Parent2 4303253584
Removing Child 4303256464
Removing Child 4303043344
当weakref
引用悬空时(因为引用被破坏),我们有几个潜在的响应:
- 重新创建引用对象。您可以从数据库中重新加载它。
- 使用
warnings
模块在内存不足的情况下写入调试信息,此时垃圾收集器意外删除了对象,并尝试在降级模式下继续。 - 别理它。
通常情况下,weakref
在对象被移除后,由于非常好的原因,weakref
引用被挂起:变量已超出范围,命名空间不再使用,或者应用程序正在关闭。出于这些原因,第三种反应相当普遍。试图创建引用的对象可能也将被删除。
__del__()
最常见的用途是确保文件已关闭。
通常,打开文件的类定义类似于以下代码所示:
__del__ = close
这将确保__del__()
方法也是close()
方法。当不再需要该对象时,该文件将被关闭,并且可以释放任何操作系统资源。
任何比这更复杂的事情都最好使用上下文管理器来完成。有关上下文管理器的更多信息,请参见第 6 章、使用可调用对象和上下文。
__new__()
方法的一个用例是初始化不可变的对象。__new__()
方法是在__init__()
方法设置对象属性值之前创建未初始化对象的方法。
必须重写__new__()
方法以扩展不使用__init__()
方法的不可变类。
以下是一个不起作用的类。我们将定义一个版本的float
,其中包含有关单位的信息:
class Float_Fail( float ):
def __init__ ( self , value: float , unit: str ) -> None :
super (). __init__ (value)
self .unit = unit
我们正在尝试(不正确地)初始化不可变对象。由于不可变对象的状态不能更改,__init__()
方法没有意义,也没有使用。
下面是我们尝试使用此类定义时发生的情况:
>>> s2 = Float_Fail(6.5, "knots")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: float expected at most 1 arguments, got 2
由此,我们可以看出,我们无法覆盖内置不可变float
类的__init__()
方法。我们对所有其他不可变类都有类似的问题。我们不能在不可变对象self
上设置属性值,因为这会破坏不可变性的定义。我们只能在对象构造期间设置属性值。__new__()
方法支持这种处理。
__new__()
方法是类方法:它将接收class
对象作为第一个参数值。这在不使用@classmethod
装饰器的情况下是正确的。它不使用self
变量,因为它的任务是创建最终将分配给self
变量的对象。
对于我们定义的任何类,__new__()
的默认实现都是从父类继承的。class
对象隐式地是所有类的父对象。object.__new__()
方法构建了一个简单、空的所需类对象。__new__()
的参数和关键字(cls
参数除外)作为标准 Python 行为的一部分传递给__init__()
。
以下是此默认行为不完美的两种情况:
- 当我们想要对一个不可变的类定义进行子类化时。我们下一步会看这个。
- 当我们需要创建元类时。这是下一节的主题,因为它与创建不可变对象有根本的不同。
在创建内置不可变类型的子类时,我们必须在创建时通过重写__new__()
来调整对象,而不是重写__init__()
。下面是一个示例类定义,它向我们展示了扩展float
的正确方法:
class Float_Units( float ):
def __new__ ( cls , value, unit):
obj = super (). __new__ ( cls , float (value))
obj.unit = unit
return obj
__new__()
的这个实现做了两件事。它创建了一个带有浮点值的新Float_Units
对象。它还向正在创建的实例中注入一个额外的unit
属性。
对于__new__()
的这种用法,很难提供适当的类型提示。mypy 版本 0.630 使用的 typeshed 中定义的方法与底层实现不完全对应。对于这种罕见的情况,类型提示似乎对预防问题没有帮助。
以下代码段为我们提供了一个带有附加单位信息的浮点值:
>>> speed = Float_Units(6.8, "knots")
>>> speed*2
13.6
>>> speed.unit 'knots'
请注意,像speed * 2
这样的表达式不会创建Float_Units
对象。该类定义继承了float
的所有运算符特殊方法;float
算术特殊方法都创建float
对象。创建Float_Units
对象将在第 8 章、创建数字中介绍。
__new__()
方法的另一个用例是创建一个元类来控制类定义的构建方式。使用__new__()
构建class
对象与使用__new__()
构建新的不可变对象有关,如前所示。在这两种情况下,__new__()
让我们有机会在__init__()
不相关的情况下进行细微修改。
元类用于构建类。一旦建立了一个class
对象,class
对象用于建立instance
对象。所有类定义的元类都是type
。type()
函数在应用程序中创建class
对象。此外,type()
函数可用于显示对象的类。
下面是一个愚蠢的例子,直接使用type()
作为构造函数构建一个几乎无用的新类:
Useless = type("Useless", (), {})
为了创建一个新类,type()
函数被赋予一个类的字符串名、一个超类元组和一个用于初始化任何class
变量的字典。返回值是一个class
值。一旦我们创建了这个类,我们就可以创建这个Useless
类的对象。但是,这些对象不会做很多事情,因为它们没有方法或属性。
我们可以使用这个新创建的Useless
类来创建对象,只要花很少的钱。以下是一个例子:
>>> Useless = type("Useless", (), {})
>>> u = Useless()
>>> u.attribute = 1
>>> dir(u) ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'attribute']
本例创建了一个Useless``u
的实例。通过对u.attribute
的赋值,可以很容易地向此类的对象添加属性。
这几乎等同于定义最小类,如下所示:
from types import SimpleNamespace
Useless2 = SimpleNamespace
class Useless3: pass
Useless2
的定义是types
模块中的SimpleNamespace
类。Useless3
的定义使用 Python 语法创建一个类,该类是object
的默认实现。这些都有几乎相同的行为。
这就引出了一个重要的问题:我们为什么要首先改变类的定义方式?
答案是类的一些默认特性并不完全适用于某些边缘情况。我们将讨论三种可能需要引入元类的情况:
- 我们可以使用元类向类添加属性或方法。注意,我们将这些添加到类本身,而不是类的任何实例。使用元类的原因是为了简化大量类似类的创建。在许多方面,向方法中添加
@classmethod
装饰器类似于创建元类。 - 元类用于创建抽象基类(ABC),我们将在第 4 章、属性访问、属性和描述到第 7 章、创建容器和集合中看到。ABC 依赖元类
__new__()
方法来确认具体的子类是完整的。我们将在第 5 章中介绍这一点,一致设计的 ABCs。 - 元类可用于简化对象序列化的某些方面。我们将在第 10 章、序列化和保存—JSON、YAML、Pickle、CSV 和 XML中了解这一点。当大量类都将使用类似的序列化技术时,元类可以确保所有应用程序类都有一个公共的序列化方面。
一般来说,有很多事情可以在元类中完成,而 mypy 工具无法理解这些事情。在定义元类的细节上挣扎并不总是有帮助的。
当我们有大量的类都需要一个记录器时,可以方便地将特性集中在一个定义中。有许多方法可以做到这一点,其中之一是提供元类定义,该定义构建一个由类的所有实例共享的类级记录器。
配方包括以下三个部分:
- 创建一个元类。元类的
__new__()
方法将向构造的类定义添加属性。 - 创建基于元类的抽象超类。这个抽象类将简化应用程序类的继承。
- 创建从元类获益的抽象超类的子类。
下面是一个示例元类,它将向类定义中注入记录器:
import logging
class LoggedMeta( type ):
def __new__ (
cls : Type,
name: str ,
bases: Tuple[Type, ...],
namespace: Dict[ str , Any]
) -> 'Logged' :
result = cast( 'Logged' , super (). __new__ ( cls , name, bases, namespace))
result.logger = logging.getLogger(name)
return result
class Logged( metaclass =LoggedMeta):
logger: logging.Logger
LoggedMeta
类使用__new__()
方法的新版本扩展了内置默认元类type
。
__new__()
元类方法在类主体元素添加到名称空间后执行。参数值是元类、要构建的新类名、一个超类元组和一个命名空间,其中包含用于初始化新类的所有类项。这个示例很典型:它使用super()
将__new__()
的实际工作委托给超类。这个元类的超类是内置的type
类
本例中的__new__()
方法还向类定义中添加了一个属性logger
。在编写类时未提供此功能,但每个使用此元类的类都可以使用此功能。
在定义新的抽象超类Logged
时,我们必须使用元类。请注意,超类包含对logger
属性的引用,该属性将由元类注入。此信息对于使注入的属性对 mypy 可见至关重要。
然后,我们可以使用这个新的抽象类作为我们定义的任何新类的超类,如下所示:
class SomeApplicationClass(Logged):
def __init__ ( self , v1: int , v2: int ) -> None :
self .logger.info( "v1=%r, v2=%r" , v1, v2)
self .v1 = v1
self .v2 = v2
self .v3 = v1*v2
self .logger.info( "product=%r" , self .v3)
SomeApplication
类的__init__()
方法依赖于类定义中可用的logger
属性。logger
属性由元类添加,名称基于类名。不需要额外的初始化或设置开销来确保Logged
的所有子类都有loggers
可用。
我们已经研究了一些基本的特殊方法,它们是我们设计的任何类的基本特性。这些方法已经是每个类的一部分,但是我们从对象继承的默认值可能不符合我们的处理要求。
我们几乎总是需要覆盖__repr__()
、__str__()
和__format__()
。这些方法的默认实现根本没有什么帮助。
除非我们正在编写自己的收藏,否则我们很少需要重写__bool__()
。这就是第 7 章创建容器和集合的主题。
我们经常需要覆盖比较和__hash__()
方法。这些定义适用于简单的不可变对象,但根本不适用于可变对象。我们可能不需要编写所有的比较运算符;我们来看看第 9 章中的@functools.total_ordering
装饰器,装饰器和混合-横切方面。
其他两个基本的特殊方法名称__new__()
和__del__()
用于更专门的目的。使用__new__()
扩展不可变类是此方法函数最常见的用例。
这些基本的特殊方法,连同__init__()
,将出现在我们编写的几乎每一个类定义中。其余的特殊方法用于更专门的目的;它们分为六类:
- 属性访问:这些特殊方法实现了我们在表达式中看到的
object.attribute
,在赋值的左侧看到的object.attribute
,在del
语句中看到的object.attribute
。 - 可调用对象:一个特殊的方法实现了我们所看到的应用于参数的函数,非常类似于内置的
len()
函数。 - 集合:这些特殊方法实现了集合的众多功能。这涉及到诸如
sequence[index]
、mapping[key]
和set | set
等操作。 - 数字:这些特殊方法提供算术运算符和比较运算符。我们可以使用这些方法来扩展 Python 使用的数字域。
- 上下文:我们将使用两种特殊方法来实现与
with
语句一起工作的上下文管理器。 - 迭代器:有一些特殊的方法定义迭代器。这不是必需的,因为生成器函数非常优雅地处理此功能。不过,我们将看看如何设计自己的迭代器。
在下一章中,我们将讨论属性、属性和描述符。