解读《Effective Python 3rd Edition》:从练气到老魔(第七章 Item 56 - 57)

发布时间:2026/7/3 2:49:16
解读《Effective Python 3rd Edition》:从练气到老魔(第七章 Item 56 - 57) Cloud_Shy 陪你解读《Effective Python 3rd Edition》从练气到老魔第七章 Classes and Interfaces类与接口作为一种面向对象编程语言Python 支持各种特性如继承、多态和封装。在 Python 中完成任务通常需要编写新的类并定义它们如何通过接口和关系进行交互。类与继承机制使得用对象来表述 Python 程序的预期行为变得十分简便。它们使您能够随着时间的推移不断完善和扩展功能。在需求不断变化的环境中这些机制提供了灵活性。熟练掌握类与继承的使用方法有助于您编写易于维护的代码。Python 也是一种多范式语言它鼓励采用函数式编程风格。函数对象属于第一类这意味着它们可以像普通变量一样被传递。Python 还允许你在同一程序中使用混合的面向对象风格与函数式风格特性这种方式可能比各自独立使用任何一种风格都更为强大。Item 56倾向于使用数据类来创建不可变对象在 Python 中几乎一切内容都可在运行时进行修改这是该语言理念中的一个基本要素参见 Item 55 和 Item 3。然而这种灵活性往往会导致一些难以调试的问题。减少可能出现问题的范围的一种方法是在对象创建后不允许对其进行修改。这一要求迫使代码的编写采用功能式风格其中函数和方法的主要目的便是始终如一地将输入映射为输出类似于数学方程式的处理方式。以这种风格编写的函数很容易测试。您只需考虑参数和返回值的等效性而不必担心对象引用和身份。推理和修改不会进行可变状态转换或导致外部副作用的函数是很简单的。通过返回以后无法修改的值函数可以避免下游意外。通过创建不可变的对象你便能够利用这些优势来使用你自己的数据类型。内置的 dataclasses 模块详见 Item 51提供了一种定义此类对象的方法这种方法远胜于使用 Python 的标准面向对象特性。dataclasses 还内置了其他功能例如能够将值对象用作字典中的键以及集合中的成员。防止对象被修改在 Python 中函数的所有参数都是通过引用传递的。遗憾的是这会导致调用者的数据可以被任何被调用者修改详情见 Item 30。这种行为可能会引发各种令人困惑的 bug。例如这里我定义了一个标准类用于表示二维空间中一个标记点的位置class Point: def __init__(self, name, x, y): self.name name self.x x self.y y我可以定义一个行为规范的辅助函数用于计算两点间的距离且不会修改输入参数def distance(left, right): return ((left.x -right.x) ** 2 (left.y -right.y) ** 2) ** 0.5 origin1 Point(source, 0, 0) point1 Point(destination, 3, 4) print(distance(origin1, point1)) 5.0我还可以定义一个行为欠佳的函数该函数会覆盖第一个参数中 x 的值def bad_distance(left, right): left.x -3 return distance(left, right)这种修改会导致错误的计算结果产生并且会永久性地改变原对象的状态从而使后续的运算结果也变得不准确print(bad_distance(origin1, point1)) print(origin1.x) 7.211102550927978 -3我可以通过实现__setattr__和__delattr__特殊方法并让它们抛出AttributeError异常来防止在标准类中发生此类修改行为有关信息请参阅 Item 61“使用__getattr__、__getattribute__和__setattr__实现惰性属性”。为了设置初始属性值我直接在__dict__对象字典中赋值键值对class ImmutablePoint: def __init__(self, name, x, y): self.__dict__.update(namename, xx, yy) def __setattr__(self, key, value): raise AttributeError(Immutable object: set not allowed) def __delattr__(self, key): raise AttributeError(Immutable object: del not allowed)现在我可以像以前一样进行同样的距离计算并得出正确的答案origin2 ImmutablePoint(source, 0, 0) assert distance(origin2, point1) 5但使用这个行为欠佳、会修改自身输入的函数时会引发异常bad_distance(origin2, point1) Traceback ... AttributeError: Immutable object: set not allowed若想使用内置的 dataclasses 模块实现相同的功能我所需要做的仅仅是将 frozen 标志传递给 dataclass 装饰器即可from dataclasses import dataclass dataclass(frozenTrue) class DataclassImmutablePoint: name: str x: float y: float origin3 DataclassImmutablePoint(origin, 0, 0) assert distance(origin3, point1) 5尝试修改此新数据类的属性时运行时将会引发类似的 AttributeError 错误bad_distance(origin3, point1) Traceback ... FrozenInstanceError: cannot assign to field x此外这种数据类方法还能使静态分析工具在程序执行前便能够检测到此类问题详情请见 Item 124“考虑通过类型分析进行静态分析以规避错误”from dataclasses import dataclass dataclass(frozenTrue) class DataclassImmutablePoint: name: str x: float y: float origin DataclassImmutablePoint(origin, 0, 0) origin.x -3你还可以利用内置模块中的 Final 和 Never 功能使标准类同样无法通过静态分析但所需的代码量要大得多from typing import Any, Final, Never class ImmutablePoint: name: Final[str] x: Final[int] y: Final[int] def __init__(self, name: str, x: int, y: int) - None: self.name name self.x x self.y y def __setattr__(self, key: str, value: Any) - None: if key in self.__annotations__ and key not in dir(self): # Allow the very first assignment to happen super().__setattr__(key, value) else: raise AttributeError(Immutable object: set not allowed) def __delattr__(self, key: str) - Never: raise AttributeError(Immutable object: del not allowed)创建被替换属性的对象副本当对象是不可变时一个自然而然的问题便会浮现如果对数据结构进行修改已不可能那么该如何编写能够实现任何功能的代码呢例如这里有一个辅助函数用于将一个 Point 对象相对移动一段距离def translate(point, delta_x, delta_y): point.x delta_x point.y delta_y正如预期的那样当输入对象为不可变时该方法会失败point1 ImmutablePoint(destination, 5, 3) translate(point1, 10, 20) Traceback ... AttributeError: Immutable object: set not allowed解决这一局限性的方法之一是返回给定参数的副本同时更新其中的属性值def translate_copy(point, delta_x, delta_y): return ImmutablePoint( namepoint.name, xpoint.x delta_x, ypoint.y delta_y, )但是这很容易出错因为你需要复制未修改的所有属性例如本例中的 name。 随着时间的推移随着类添加、删除或更改属性这种复制代码可能会不同步并导致程序中出现神秘的错误。为了降低标准类中出现此类错误的风险我在这里添加了一个方法该方法能够使用给定的一组属性覆盖创建对象的副本class ImmutablePoint: def __init__(self, name, x, y): self.__dict__.update(namename, xx, yy) def __setattr__(self, key, value): raise AttributeError(Immutable object: set not allowed) def __delattr__(self, key): raise AttributeError(Immutable object: del not allowed) def _replace(self, **overrides): fields dict( nameself.name, xself.x, yself.y, ) fields.update(overrides) cls type(self) return cls(**fields)现在代码可以依靠_replace方法来确保正确考虑所有属性。这里定义了使用该方法的 translate 函数的另一个版本def translate_replace(point, delta_x, delta_y): return point._replace( # Changed xpoint.x delta_x, ypoint.y delta_y, )请注意 name 属性不再被提及。但这种方法仍然不理想。尽管已将字段复制代码集中到类内的一个位置但 _replace 方法仍然有可能不同步因为它需要手动维护。此外每个需要此功能的类都必须定义自己的 _replace 方法这会导致需要管理更多样板代码。要使用 dataclass 完成相同的行为我可以简单地使用 dataclasses 模块中的 replace 辅助函数不需要更改类定义不需要定义自定义 _replace 方法并且该方法不可能不同步import dataclasses def translate_dataclass(point, delta_x, delta_y): return dataclasses.replace( # Changed point, xpoint.x delta_x, ypoint.y delta_y, )在字典和集合中使用不可变对象当你将相同的键分配给字典中的不同值时你期望只保留最终的映射my_dict {} my_dict[a] 123 my_dict[a] 456 print(my_dict) {a: 456}类似地当您向集合中添加一个值时您预计同一值的所有后续添加都不会导致集合发生任何更改因为该值已经存在my_set set() my_set.add(b) my_set.add(b) print(my_set) {b}这些稳定的映射和重复数据删除行为是对这些数据结构如何工作的关键期望。令人意外的是默认情况下用户定义的对象不能像上面代码中的简单值 “a” 和 “b” 一样用作字典键或设置值。例如假设我想编写一个程序来模拟电的物理特性。在此处我创建了一个字典它将点对象映射到该位置上的电荷量可能还存在其他字典将相同的点对象映射到其他量值如磁通量等。:point1 Point(A, 5, 10) point2 Point(B, -7, 4) charges { point1: 1.5, point2: 3.5, }从字典中检索给定点的值似乎可行print(charges[point1]) 1.5然而如果我创建另一个看似与第一个 Point object 相同的对象——具有相同的坐标和名称——那么在通过字典进行查找时会引发 KeyError 异常point3 Point(A, 5, 10) charges[point3] Traceback ... KeyError: __main__.Point object at 0x100e85eb0经过进一步检查后发现这些 Point objects 并不被视为等同对象原因在于我尚未为该类实现__eq__特殊方法assert point1 ! point3对于对象来说运算符的默认实现与仅比较其标识的is运算符相同。在此处我实现了__eq__特殊方法以便它能比较对象属性值的差异class Point: def __init__(self, name, x, y): self.name name self.x x self.y y def __eq__(self, other): return ( type(self) type(other) and self.name other.name and self.x other.x and self.y other.y )现在两个看似等同的点对象也将被运算符视为等同point4 Point(A, 5, 10) point5 Point(A, 5, 10) assert point4 point5然而即便有了这些新的等价对象从较早时期开始的字典查找操作依然无法完成other_charges { point4: 1.5, } other_charges[point5] Traceback ... TypeError: unhashable type: Point问题是 Point 类没有实现__hash__特殊方法。Python 的字典类型实现依赖于__hash__方法返回的整数值来维护其内部查找表。为了使字典正常工作这个哈希值对于单个对象必须是稳定且不变的并且对于等效对象来说它必须是相同的。这里我通过将对象的属性放入元组中并将其传递给 hash 内置函数来实现__hash__方法class Point: def __init__(self, name, x, y): self.name name self.x x self.y y def __eq__(self, other): return ( type(self) type(other) and self.name other.name and self.x other.x and self.y other.y ) def __hash__(self): return hash((self.name, self.x, self.y))现在字典查询功能已按预期运行:point6 Point(A, 5, 10) point7 Point(A, 5, 10) more_charges { point6: 1.5, } value more_charges[point7] assert value 1.5借助数据类要使用一个不可变对象作为字典中的键完全无需进行上述任何操作。当你向数据类装饰器传入 frozenflag 参数时便可自动获得所有这些行为例如__eq__、__hash__等:point8 DataclassImmutablePoint(A, 5, 10) point9 DataclassImmutablePoint(A, 5, 10) easy_charges { point8: 1.5, } assert easy_charges[point9] 1.5这些不可变的对象还可被用作集合中的值并且能够有效地消除重复my_set {point8, point9} assert my_set {point8}那命名元组呢在数据类被添加至 Python 标准库版本 3.7之前用于创建不可变对象的一个良好选择是内置模块 collections 中的 namedtuple 函数。namedtuple 提供了与使用 frozen 标志的数据类装饰器相似的诸多优势包括构造对象时可使用位置参数或关键字参数当属性未指定时系统会自动提供默认值。对象导向的特殊方法的自动定义例如__init__、__repr__、__eq__、__hash__、__lt__等。内置辅助方法_replace和_dict以及借助_fields和_field_defaults类属性进行运行时探查功能。在使用内置模块 typing 中的 NamedTuple 类时支持静态类型检查功能。通过避免使用__dict__实例字典即类似于使用带有 slotsTrue 参数的 dataclasses来降低内存占用量。此外命名元组的各个字段均可通过位置索引进行访问这非常适用于封装诸如 CSV逗号分隔值文件中的行或数据库查询结果中的列等序列化数据结构——使用数据类时必须调用_astuple方法。然而namedtuple 的顺序性质可能会导致无意的使用即数字索引和迭代从而导致错误并使以后难以迁移到标准类特别是对于外部 APIs请参阅 Item 119“使用包来组织模块并提供稳定的 APIs”。 如果你的数据结构是顺序的那么 namedtuple 可能是一个不错的选择但否则最好使用数据类或标准类参见 Item 65“考虑类体定义顺序来建立属性之间的关系”。注意使用不可变对象的函数式风格代码通常比修改状态并引发副作用的过程式风格代码更加稳健。创建你自己的不可变对象最简单的方法是使用内置的 dataclasses 模块只需在定义类时应用 dataclass 装饰器并传入 frozenTrue 参数即可。使用 dataclasses 模块的 replace 辅助函数可使您创建带有某些属性已更改的不可变对象的副本从而便于编写函数式风格的代码。使用 dataclass 创建的不可变对象在值相等性方面具有可比性且拥有稳定的哈希值这使得它们能够被用作字典中的键以及集合中的值。Item 57从 collections.abc 类中继承自定义容器类型在 Python 编程中很大一部分内容都涉及定义包含数据的类并描述这些对象之间如何相互关联。每个 Python 类都是一种容器同时封装了属性与功能。Python 还提供了内置的容器类型来管理数据列表、元组、集合和字典。当你为诸如序列等简单用例设计类时自然而然地会想要直接继承 Python 内置的 list 类。例如假设我想要创建自己的一套自定义 list 类该类应具备用于统计其成员出现频率的额外方法class FrequencyList(list): def __init__(self, members): super().__init__(members) def frequency(self): counts {} for item in self: counts[item] counts.get(item, 0) 1 return counts通过对列表进行子类化我获得了列表的所有标准功能并保留了所有 Python 程序员都熟悉的语义。我可以定义其他方法来提供我需要的任何自定义行为foo FrequencyList([a, b, a, c, b, a, d]) print(Length is, len(foo)) foo.pop() # Removes d print(After pop:, repr(foo)) print(Frequency:, foo.frequency())现在假设我需要定义一个类似于列表并允许索引但不是列表子类的对象。例如假设我想为二叉树类提供序列语义如列表或元组请参阅 Item 14“了解如何对序列进行切片” 了解背景class BinaryNode: def __init__(self, value, leftNone, rightNone): self.value value self.left left self.right right如何使这个类表现得像序列类型Python 使用具有特殊名称的实例方法来实现其容器行为。当您通过索引访问序列项时bar [1, 2, 3] bar[0]它将被解释为bar.__getitem__(0)为了使 BinaryNode 类表现得像一个序列您可以提供__getitem__的自定义实现通常发音为 “dunder getitem”是 “双下划线 getitem” 的缩写它深度优先遍历对象树class IndexableNode(BinaryNode): def _traverse(self): if self.left is not None: yield from self.left._traverse() yield self if self.right is not None: yield from self.right._traverse() def __getitem__(self, index): for i, item in enumerate(self._traverse()): if i index: return item.value raise IndexError(fIndex {index} is out of range)这里我用普通的对象初始化构造了一个二叉树tree IndexableNode( 10, leftIndexableNode( 5, leftIndexableNode(2), rightIndexableNode(6, rightIndexableNode(7)), ), rightIndexableNode(15, leftIndexableNode(11)), )但除了能够使用 left 和 right 属性遍历树之外我还可以像列表一样访问它print(Example 8) print(LRR is, tree.left.right.right.value) print(Index 0 is, tree[0]) print(Index 1 is, tree[1]) print(11 in the tree?, 11 in tree) print(17 in the tree?, 17 in tree) print(Tree is, list(tree)) LRR is 7 Index 0 is 2 Index 1 is 5 11 in the tree? True 17 in the tree? False Tree is [2, 5, 6, 7, 10, 11, 15]问题是实现__getitem__不足以提供 Python 期望从列表实例中获得的所有序列语义len(tree) Traceback ... TypeError: object of type IndexableNode has no len()len 内置函数需要另一个特殊方法__len__它必须具有自定义序列类型的实现class SequenceNode(IndexableNode): def __len__(self): count 0 for _ in self._traverse(): count 1 return count tree SequenceNode( 10, leftSequenceNode( 5, leftSequenceNode(2), rightSequenceNode(6, rightSequenceNode(7)), ), rightSequenceNode(15, leftSequenceNode(11)), ) print(Tree length is, len(tree)) Tree length is 7不幸的是这仍然不足以让类完全充当有效的序列。还缺少 Python 程序员期望在列表或元组等序列上看到的计数和索引方法。事实证明定义自己的容器类型比看起来要困难得多。为了在整个 Python 世界中避免这种困难collections.abc 内置模块定义了一组抽象基类为每种容器类型提供所有典型方法。当您从这些抽象基类派生子类并忘记实现所需的方法时该模块会告诉您出现了问题from collections.abc import Sequence class BadType(Sequence): pass foo BadType() Traceback ... TypeError: Cant instantiate abstract class BadType without an ➥implementation for abstract methods __getitem__, __len__当您实现 collections.abc 中的抽象基类所需的所有方法时就像我上面使用 SequenceNode 所做的那样它免费提供所有附加方法例如 index 和 count class BetterNode(SequenceNode, Sequence): pass tree BetterNode( 10, leftBetterNode( 5, leftBetterNode(2), rightBetterNode(6, rightBetterNode(7)), ), rightBetterNode(15, leftBetterNode(11)), ) print(Index of 7 is, tree.index(7)) print(Count of 10 is, tree.count(10)) Index of 7 is 3 Count of 10 is 1对于更复杂的容器类型例如 Set 和 Mutable Mapping使用这些抽象基类的好处甚至更大它们需要实现大量特殊方法来匹配 Python 约定。除了 collections.abc 模块之外Python 还使用各种特殊方法进行对象比较和排序这些方法可能由容器类和非容器类提供例如请参阅 Item 104“了解如何使用 heapq 作为优先级队列” 和 Item 51“首选数据类来定义轻量级类”。注意对于简单的用例可以直接从 Python 容器类型如 list 或 dict继承来利用它们的基本行为。当不从内置类型继承时请注意正确实现自定义容器类型所需的大量方法。为了确保您的自定义容器类符合所需的行为请让它们继承 collections.abc 中定义的接口。