第十一章 符合 Python 风格的对象
一个库或框架是否符合 Python 风格,要看它能不能让 Python 程序员以一种简单而自然的方式执行任务。
对象表示形式
Python 提供两种标准对象字符串表示方式:
| 函数/方法 | 用途 | 调用场景 |
|---|---|---|
repr() | 开发者友好的表示 | 控制台、调试器显示对象 |
str() | 用户友好的表示 | print() 输出 |
__repr__():支持repr()__str__():支持str()__bytes__():支持bytes(),返回bytes类型__format__(format_spec):支持format()、str.format()和 f 字符串
__repr__、__str__、__format__ 必须返回 Unicode 字符串(str 类型);
只有 __bytes__ 应返回 字节序列(bytes 类型)。
class Vector2d:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
def __repr__(self):
return f'Vector2d({self.x!r}, {self.y!r})'
def __str__(self):
return f'({self.x}, {self.y})'
def __bytes__(self):
from array import array
return bytes(array('d', [self.x, self.y]))
def __format__(self, format_spec=''):
components = (format(c, format_spec) for c in (self.x, self.y))
return f'({", ".join(components)})'
向量类案例
我们希望实现一个向量类,同时Vector2d 实例应具备以下特性:
- 直接属性访问:
v1.x,v1.y - 支持拆包:
x, y = v1 repr()返回可构造实例的源码形式:Vector2d(3.0, 4.0)- 可通过
eval(repr(v))重建对象 - 支持
==比较 str()返回用户友好的有序对形式:(3.0, 4.0)bytes()返回二进制表示abs()返回向量模长(欧几里得范数)bool()在模为 0 时返回False,否则True- 支持反序列化
注意:使用 eval(repr(...)) 克隆对象仅用于演示 __repr__ 的正确性;实际应使用 copy.copy,更安全高效。
from array import array
import math
class Vector2d:
typecode = 'd' # 类属性,用于字节序列转换
def __init__(self, x, y):
self.x = float(x) # 尽早转换为 float,提前暴露类型错误
self.y = float(y)
@classmethod
# 装饰器使方法在类上调用 如 Vector2d.frombytes(b'...'),而非实例
# 备选构造函数
# 从 bytes对象反序列化出 Vector2d 实例
def frombytes(cls, octets):
# 从首字节读取 typecode
typecode = chr(octets[0])
# # 跳过首字节,用 typecode 解析剩余数据
memv = memoryview(octets[1:]).cast(typecode)
# 拆包解析结果,传给构造函数
return cls(*memv)
def __iter__(self):
# 使实例可迭代,支持拆包(如 x, y = v)
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
# 使用 {!r} 获取分量的 repr 形式,*self 利用可迭代性解包
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
# 转为元组后字符串化,输出如 (3.0, 4.0)
return str(tuple(self))
def __bytes__(self):
# 先写入 typecode 字节,再写入分量的二进制数据
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
# 转为元组比较,允许与相同结构的可迭代对象相等(如 [3, 4])
return tuple(self) == tuple(other)
def __abs__(self):
# 使用 math.hypot 计算欧几里得模长
return math.hypot(self.x, self.y)
def __bool__(self):
# 模为 0 时为 False,否则为 True
return bool(abs(self))
| 特性 | 实现方式 | 说明 |
|---|---|---|
| 可迭代 | __iter__ 返回生成器表达式 | 支持拆包和 tuple(self) |
__repr__ | 格式化类名与分量的 repr | 确保输出可作为有效 Python 表达式 |
__str__ | str(tuple(self)) | 用户友好显示 |
__bytes__ | typecode + array 转 bytes | 二进制序列化格式 |
__eq__ | 比较 tuple(self) 与 tuple(other) | 允许跨类型比较,但可能引发歧义 |
__abs__ | math.hypot(x, y) | 精确计算 √(x² + y²) |
__bool__ | bool(abs(self)) | 零向量为假,非零为真 |
# 关于备选构造方法的使用 使用反序列化构建
v1 = Vector2d(3, 4)
octets = bytes(v1)
v2 = Vector2d.frombytes(octets)
print(v1 == v2) # 输出: True
classmethod 与 staticmethod
@classmethod
@classmethod的作用是定义操作类本身而非实例的方法。第一个参数自动接收类对象(通常命名为 cls)。典型用途是实现备选构造函数(如 frombytes)。可通过类或实例调用,但始终传入类作为第一个参数。
@staticmethod
@staticmethod用于定义普通函数,仅因逻辑关联而放在类内部。不自动接收类或实例;行为与模块级函数完全相同。本质上只是命名空间上的归属,无特殊绑定行为。
class Demo:
@classmethod
def klassmeth(cls, *args):
return args
@staticmethod
def statmeth(*args):
return args
# 调用结果
print(Demo.klassmeth()) # (<class '__main__.Demo'>,)
print(Demo.klassmeth('spam')) # (<class '__main__.Demo'>, 'spam')
print(Demo.statmeth()) # ()
print(Demo.statmeth('spam')) # ('spam',)
| 特性 | @classmethod | @staticmethod |
|---|---|---|
| 第一个参数 | 自动传入类(cls) | 无自动参数 |
| 与类的关系 | 紧密,可访问或构造类实例 | 仅逻辑相关,无运行时绑定 |
| 典型用途 | 备选构造函数、类级工厂方法 | 工具函数(与类相关但不依赖类状态) |
格式化显示
Python 的格式化机制(f 字符串、format()、str.format())通过对象的__format__(format_spec) 方法实现自定义格式行为。 格式说明符(format_spec)位于代换字段冒号之后(如 {value:.2f} 中的 .2f),其语法称为格式规范微语言。
若类未定义 __format__,则继承自 object,等价于调用 str(obj)。若传入非空 format_spec,object.__format__ 会抛出 TypeError。
# 上面的类还没进行定义
v1 = Vector2d(3, 4)
format(v1) # '(3.0, 4.0)' —— 调用 __str__
format(v1, '.3f') # TypeError: non-empty format string passed to object.__format__
支持分量格式化
# 将格式说明符应用于每个分量
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self)
return '({}, {})'.format(*components)
format(Vector2d(3, 4)) # '(3.0, 4.0)'
format(Vector2d(3, 4), '.2f') # '(3.00, 4.00)'
format(Vector2d(3, 4), '.3e') # '(3.000e+00, 4.000e+00)'
支持极坐标格式
新增功能若格式说明符以 'p' 结尾,则以极坐标 <r, θ> 显示(r 为模,θ 为弧度)。
# 辅助方法
def angle(self):
return math.atan2(self.y, self.x)
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1] # 移除 'p'
coords = (abs(self), self.angle()) # (模, 角度)
outer_fmt = '<{}, {}>'
else:
coords = self # (x, y)
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
v = Vector2d(1, 1)
print(format(v, 'p')) # '<1.4142135623730951, 0.7853981633974483>'
print(format(v, '.3ep')) # '<1.414e+00, 7.854e-01>'
print(format(v, '0.5fp')) # '<1.41421, 0.78540>'
可哈希
默认情况下,Vector2d 实例不可哈希,无法用于 set 或作为 dict 的键:
v1 = Vector2d(3, 4)
hash(v1) # TypeError: unhashable type: 'Vector2d'
set([v1]) # 同样报错
要使其可哈希,需满足两个条件:
- 实现
__hash__方法; - 确保对象不可变(因为可哈希对象的值在其生命周期内必须恒定)。
实现不可变性
当前 Vector2d 的 x 和 y 是普通实例属性,可被修改。为防止意外变更,将其改为只读特性(property)。
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) # 使用双前导下划线标记为“私有”
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
# 其他方法(__repr__, __eq__, __abs__ 等)保持不变,继续通过 self.x / self.y 访问
self.__x 和 self.__y 使用名称改写(name mangling),避免外部直接访问;所有内部方法仍通过 self.x 和 self.y 访问,无需修改;尝试赋值 v1.x = 7 将引发 AttributeError。
实现 __hash__
根据 Python 规范:相等的对象必须具有相同的哈希值。 由于已实现 __eq__(基于 (x, y) 元组比较),__hash__ 也应基于相同分量计算。
def __hash__(self):
return hash((self.x, self.y))
v1 = Vector2d(3, 4)
v2 = Vector2d(3.1, 4.2)
print(hash(v1), hash(v2)) # 输出两个整数
print({v1, v2}) # 成功创建集合:{Vector2d(3.0, 4.0), Vector2d(3.1, 4.2)}
可哈希 ≠ 必须使用 property,只要对象在生命周期内状态不变,即使无 property 也可哈希。但使用只读特性是保障不可变性的常用手段。
支持位置模式匹配
Python 3.10 引入了 结构化模式匹配(match 语句)。 Vector2d 默认支持关键字模式匹配,但若要支持位置模式匹配,需显式声明 __match_args__ 类属性。
def keyword_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} 是零向量')
case Vector2d(x=0):
print(f'{v!r} 是垂直向量')
case Vector2d(y=0):
print(f'{v!r} 是水平向量')
case Vector2d(x=x, y=y) if x == y:
print(f'{v!r} 是对角向量')
case _:
print(f'{v!r}')
但是如果尝试使用位置模式:
case Vector2d(_, 0): # 期望匹配 y=0 的向量
会抛出异常:
TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)
因为Python 不知道如何将位置子模式(如 0)映射到实例属性。
通过添加类属性 __match_args__,指定位置模式中各位置对应的属性名顺序:
class Vector2d:
__match_args__ = ('x', 'y') # 指定位置模式的参数顺序
# ... 其他代码不变
__match_args__ 是一个元组或序列,列出用于位置匹配的属性名;顺序必须与期望的位置参数顺序一致(此处 x 在前,y 在后);同时不必包含所有属性,通常只包含 __init__ 中的必需参数。
# 启用后代码
def positional_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(0, 0):
print(f'{v!r} is null')
case Vector2d(0, _): # x=0, y 任意
print(f'{v!r} is vertical')
case Vector2d(_, 0): # y=0, x 任意
print(f'{v!r} is horizontal')
case Vector2d(x, y) if x == y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
__match_args__ 是可选类属性,仅在需要支持位置模式匹配时定义;它不影响对象的其他行为,仅用于 match 语句的模式解析;符合 Python 风格的设计应考虑对新语言特性的兼容性。
私有属性和受保护的属性
Python 没有真正的私有属性(如 Java 的 private),但提供了两种约定机制来表达访问意图。
私有属性
私有属性以 双下划线开头(如 __x),且不以双下划线结尾。Python 在类定义时自动将 __x 改写为 _ClassName__x,存入实例的 __dict__。其目的在于防止子类意外覆盖父类的内部属性,而不是为了安全或封装。
v1 = Vector2d(3, 4)
print(v1.__dict__)
# 输出: {'_Vector2d__x': 3.0, '_Vector2d__y': 4.0}
print(v1._Vector2d__x) # 仍可直接访问
# 输出: 3.0
关于双下划线的使用在社区存在很多分歧。
受保护属性
受保护属性以单下划线开头,Python 解释器不做任何处理,纯属社区约定。表示“内部使用”,不应在类外部直接访问。实际影响在于使用 from module import *,带单下划线的顶层名称不会被导入;但可通过显式导入(如 from mod import _x)访问。
使用 __slots__ 节省空间
默认情况下,Python 将实例属性存储在 __dict__ 字典中;但是__dict__ 内存开销大,即使经过优化。通过定义类属性 __slots__,可改用更紧凑的内部结构(隐藏的引用数组)存储属性,显著减少内存占用。
class Pixel:
__slots__ = ('x', 'y') # 必须在类定义时声明,推荐用元组
p = Pixel()
# p.__dict__ # AttributeError: 'Pixel' object has no attribute '__dict__'
p.x = 10
p.y = 20
# p.color = 'red' # AttributeError: 'Pixel' object has no attribute 'color'
这将导致实例没有 __dict__;只能设置 __slots__ 中列出的属性,否则抛出 AttributeError。
继承与 __slots__ 的陷阱
若子类未定义 __slots__,则子类实例会拥有 __dict__;基类 __slots__ 中的属性仍存储在隐藏数组中;额外属性会被存入 __dict__,失去内存优化效果。
class OpenPixel(Pixel):
pass
op = OpenPixel()
print(op.__dict__) # {}(存在!)
op.x = 8 # 存入隐藏数组
op.color = 'green' # 存入 __dict__
print(op.__dict__) # {'color': 'green'}
子类若需保持无 __dict__,必须显式声明 __slots__。
class ColorPixel(Pixel):
__slots__ = ('color',) # 注意:单元素元组需加逗号
cp = ColorPixel()
# cp.__dict__ # AttributeError
cp.x = 2 # 来自基类 Pixel
cp.color = 'blue' # 来自 ColorPixel
# cp.flavor = 'banana' # AttributeError
子类 __slots__ 不会覆盖基类的,而是叠加;最终允许的属性 = 基类 __slots__ + 子类 __slots__。
应用
class Vector2d:
__match_args__ = ('x', 'y') # 用于模式匹配(公开属性名)
__slots__ = ('__x', '__y') # 实例属性名(私有)
typecode = 'd' # 类属性,不受 __slots__ 影响
# 其他方法(__init__, @property, __repr__ 等)保持不变
__slots__ 列出的是实际存储的属性名(即 __x, __y);公开接口(如 x, y)通过 @property 提供,不受影响。
__slots__ 可显著节省内存并提升性能,尤其适用于大量轻量对象。但是对于大规模数值计算,应优先考虑 NumPy 数组,而非自定义类。
总结
- 子类必须重新声明
__slots__,否则会获得__dict__; - 属性受限:只能使用
__slots__中列出的名称(除非加入'__dict__'); - 与
@cached_property不兼容,除非包含'__dict__'; - 弱引用支持需显式添加
'__weakref__'; - 类属性(如
typecode)不受影响,__slots__仅限制实例属性。
覆盖类属性
Python 中,类属性可被实例继承作为默认值;当通过 self.attr 访问一个不存在的实例属性时,Python 会回退到类属性;若对 self.attr 赋值,则创建同名实例属性,遮盖(shadow) 类属性,但不修改类本身。
示例
Vector2d 定义了类属性 typecode = 'd'(双精度浮点),用于 __bytes__ 方法:
# 默认行为
v1 = Vector2d(1.1, 2.2)
dumpd = bytes(v1) # b'd...',长度 17 字节
v1.typecode = 'f' # 创建实例属性 typecode
# 仅该实例使用单精度('f'),其他实例不受影响。
dumpf = bytes(v1) # b'f...',长度 9 字节
print(Vector2d.typecode) # 仍为 'd',类属性未变
实现的前提是此类 Vector2d 不能使用 __slots__,否则无法动态添加 typecode 实例属性。(若对 self.attr 赋值,会创建同名实例属性)
通过子类定制
若需批量或持久化修改默认行为,应创建子类覆盖类属性:
class ShortVector2d(Vector2d):
typecode = 'f' # 覆盖父类默认值
sv = ShortVector2d(1/11, 1/27)
print(sv) # ShortVector2d(0.0909..., 0.0370...)
print(len(bytes(sv))) # 9(使用单精度)
Vector2d.__repr__ 使用 type(self).__name__ 而非硬编码类名:
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
好处在于,对于子类(如 ShortVector2d)无需重写 __repr__ 即可正确显示自身类名。
348

被折叠的 条评论
为什么被折叠?



