Skip to main content

面向对象进阶

本章深入探讨 Python 的对象模型,包括变量引用的本质、如何编写地道的 Python 类、序列协议、抽象基类、多重继承和运算符重载。

变量不是盒子

在 Python 中,变量不是存储数据的盒子,而是贴在对象上的标签。先看一个例子:

>>> a = [1, 2, 3]
>>> b = a
>>> b.append(4)
>>> a
[1, 2, 3, 4]

ab 指向同一个列表对象,通过 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 obj
  • in 运算符
  • 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 决定了多重继承中方法的查找顺序
  • 混入类提供可复用的功能,应该放在继承列表左边
  • 运算符重载要遵循中缀运算符不修改操作对象的原则