面向对象进阶
本章深入探讨 Python 的对象模型,包括变量引用的本质、如何编写地道的 Python 类、序列协议、抽象基类、多重继承和运算符重载。
变量不是盒子
在 Python 中,变量不是存储数据的盒子,而是贴在对象上的标签。先看一个例子:
>>> a = [1, 2, 3]
>>> b = a
>>> b.append(4)
>>> a
[1, 2, 3, 4]
a 和 b 指向同一个列表对象,通过 b 修改, a 也会看到变化。这是因为赋值语句 b = a 并没有复制列表,只是让 b 也引用了 a 所指的对象。
== vs is
==比较的是值(调用__eq__)is比较的是身份(内存地址)
>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> a == b
True
>>> a is b
False
浅复制与深复制
复制列表有常见三种方式:
>>> a = [1, [2, 3], 4]
# 方式1:切片(浅复制)
>>> b = a[:]
# 方式2:list() 构造函数(浅复制)
>>> c = list(a)
# 方式3:copy 模块
>>> import copy
>>> d = copy.copy(a) # 浅复制
>>> e = copy.deepcopy(a) # 深复制
浅复制只复制最外层对象,内部对象仍然共享引用:
>>> a = [1, [2, 3], 4]
>>> b = a[:]
>>> b[1].append(99)
>>> a
[1, [2, 3, 99], 4] # a 也变了!
深复制递归复制所有嵌套对象:
>>> c = copy.deepcopy(a)
>>> c[1].append(100)
>>> a
[1, [2, 3, 99], 4] # a 不受影响
函数的参数传递
Python 的参数传递方式是按共享传参(call by sharing):函数内部拿到的是实参引用的副本。对于不可变对象(数字、字符串、元组),修改参数不会影响外部;对于可变对象(列表、字典),修改会影响外部:
def append_item(seq, item):
seq.append(item) # 修改可变对象,外部可见
def rebind(seq, item):
seq = seq + [item] # 重新绑定局部变量,外部不可见
a = [1, 2]
append_item(a, 3)
print(a) # [1, 2, 3]
rebind(a, 4)
print(a) # [1, 2, 3] 没变!
符合 Python 风格的对象
对象表示形式
每个 Python 对象都应该有合理的字符串表示:
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 __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __abs__(self):
import math
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
__repr__:给开发者看,准确、无歧义,最好能直接 eval__str__:给用户看,友好__eq__:判断两个对象是否相等__abs__:abs(v)的语义__bool__:对象的布尔值
classmethod 与 staticmethod
@classmethod 定义类方法,第一个参数是类本身(通常命名为 cls):
class Vector2d:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def from_bytes(cls, octets):
# 从字节序列构造对象
import struct
fmt = cls.COMPONENT_FMT
memv = memoryview(octets).cast(fmt)
return cls(*memv)
类方法常用于定义备选构造器(alternative constructor)。
@staticmethod 定义静态方法,它不接收隐式的第一个参数:
class Vector2d:
@staticmethod
def angle_between(v1, v2):
import math
dot = v1.x * v2.x + v1.y * v2.y
return math.acos(dot / (abs(v1) * abs(v2)))
静态方法只是恰好放在类里的普通函数,和类没有特别的耦合。
使用 slots 节省内存
默认情况下,Python 类的每个实例都用字典存储属性,内存开销较大。如果类的属性是固定的,可以用 __slots__ 来优化:
class Member:
__slots__ = ['name', 'role', 'score']
def __init__(self, name, role, score=0):
self.name = name
self.role = role
self.score = score
使用 __slots__ 的好处:
- 实例不再创建
__dict__,节省内存 - 属性访问更快
- 防止动态添加属性(
m.email = '...'会报错)
缺点:
- 不能动态添加新属性
- 多重继承时多个父类都有
__slots__会很复杂
序列协议
Python 通过协议(protocol)而非继承来支持多态。要让一个类表现得像序列,只需要实现 __len__ 和 __getitem__:
class MemberList:
def __init__(self):
self._members = []
def add(self, member):
self._members.append(member)
def __len__(self):
return len(self._members)
def __getitem__(self, index):
return self._members[index]
实现了这两个方法,这个类就自动支持:
len(obj)obj[index]和obj[start:stop]for x in objin运算符reversed(obj)(如果还实现了__reversed__)
切片原理
当用 obj[start:stop:step] 时,Python 会构造一个 slice 对象传给 __getitem__:
class MySeq:
def __getitem__(self, index):
if isinstance(index, slice):
print(f"切片: start={index.start}, stop={index.stop}, step={index.step}")
else:
print(f"索引: {index}")
# ...
散列与不可变性
要让自定义对象可以作为字典的键或存入集合,需要实现 __hash__。可散列对象必须是不可变的:
class Vector2d:
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 __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
关键点是:
- 用
@property把属性设为只读 __eq__和__hash__必须同时实现__hash__应该基于不可变的属性值
接口与抽象基类
协议与鸭子类型
Python 崇尚鸭子类型(duck typing):不检查对象的类型,只检查对象有没有需要的方法。
def print_lengths(seq):
# 不 care seq 是 list、tuple 还是自定义类
# 只要 seq 支持 len() 就行
print(len(seq))
这种非正式的接口称为协议(protocol)。序列协议只需要 __len__ 和 __getitem__。
collections.abc
标准库中的 collections.abc 模块定义了一系列抽象基类(ABC):
| 抽象基类 | 继承它需实现的方法 |
|---|---|
Iterable | __iter__ |
Iterator | __next__、__iter__ |
Sequence | __getitem__、__len__ |
Mapping | __getitem__、__iter__、__len__ |
Set | __contains__、__iter__、__len__ |
Callable | __call__ |
Hashable | __hash__ |
注册为抽象基类的虚拟子类:
from collections.abc import Sequence
class MyList:
def __init__(self, items):
self._items = items
def __len__(self):
return len(self._items)
def __getitem__(self, index):
return self._items[index]
Sequence.register(MyList) # 注册为虚拟子类
print(issubclass(MyList, Sequence)) # True
继承
方法解析顺序 MRO
Python 使用 C3 算法 来确定多重继承的方法解析顺序(MRO)。可以用 __mro__ 属性查看:
class A:
def method(self):
print("A.method")
class B(A):
def method(self):
print("B.method")
class C(A):
def method(self):
print("C.method")
class D(B, C):
pass
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
super() 遵循 MRO 调用下一个类的方法,而不是父类:
class D(B, C):
def method(self):
print("D.method")
super().method() # 调用 B.method
class B(A):
def method(self):
print("B.method")
super().method() # 调用 C.method(不是 A!)
混入类 Mixin
混入类(Mixin)是一种不单独使用、而是和其他类组合起来提供额外功能的类:
class JSONSerializableMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class Member(JSONSerializableMixin):
def __init__(self, name, role):
self.name = name
self.role = role
m = Member("Alice", "算法")
print(m.to_json())
混入类的最佳实践:
- 混入类应该只提供方法,不定义实例属性(或者只定义
__slots__) - 混入类名通常以
Mixin结尾 - 混入类应该放在继承列表的左边,主类放在右边
运算符重载
基本规则
- 不能自定义新的运算符,只能重载已有的
- 不能改变运算符的优先级
- 一元运算符:
__neg__、__pos__、__abs__、__invert__ - 比较运算符:
__eq__、__ne__、__lt__、__le__、__gt__、__ge__ - 算术运算符:
__add__、__sub__、__mul__、__truediv__等
反向方法
当左操作数不支持某个运算符时,Python 会尝试调用右操作数的反向方法:
class Vector2d:
def __init__(self, x, y):
self.x = x
self.y = y
def __mul__(self, scalar):
# vector * scalar
return Vector2d(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
# scalar * vector
return self * scalar
增量赋值运算符
+=、 -= 等增量赋值运算符对应 __iadd__、 __isub__ 等方法。如果没有实现这些方法,Python 会退化为 a = a + b:
class Vector2d:
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self # 必须返回 self
对于可变对象,增量赋值应该原地修改并返回 self;对于不可变对象,直接不实现,让 Python 退化为创建新对象。
小结
- Python 变量是对象的引用标签,不是存储数据的盒子
==比较值,is比较身份(内存地址)- 浅复制复制最外层,深复制递归复制所有嵌套对象
__repr__给开发者看,__str__给用户看@classmethod定义备选构造器,@staticmethod定义工具函数__slots__节省内存,但限制动态属性- 序列协议只需要
__len__和__getitem__ - 可散列对象必须不可变,且
__eq__和__hash__要一致 - Python 用鸭子类型和协议,而不是严格的接口继承
- MRO 决定了多重继承中方法的查找顺序
- 混入类提供可复用的功能,应该放在继承列表左边
- 运算符重载要遵循中缀运算符不修改操作对象的原则