第十一章 符合 Python 风格的对象

第十一章 符合 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 + arraybytes二进制序列化格式
__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

classmethodstaticmethod

@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_specobject.__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])       # 同样报错

要使其可哈希,需满足两个条件:

  1. 实现 __hash__ 方法;
  2. 确保对象不可变(因为可哈希对象的值在其生命周期内必须恒定)。

实现不可变性

当前 Vector2dxy 是普通实例属性,可被修改。为防止意外变更,将其改为只读特性(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.__xself.__y 使用名称改写(name mangling),避免外部直接访问;所有内部方法仍通过 self.xself.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 数组,而非自定义类。

总结

  1. 子类必须重新声明 __slots__,否则会获得 __dict__
  2. 属性受限:只能使用 __slots__ 中列出的名称(除非加入 '__dict__');
  3. @cached_property 不兼容,除非包含 '__dict__'
  4. 弱引用支持需显式添加 '__weakref__'
  5. 类属性(如 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__ 即可正确显示自身类名。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值