0%

流畅的 Python(5):符合 Python 风格的对象

这篇文章主要介绍 Python 的面对对象惯用法,首先介绍对象与对象名称的区别,解释对象标识、值和别名等概念,以及垃圾回收、del 命令、如何通过弱引用记住对象,而无需对象本身存在。最后会介绍符合 Python 风格的对象。

对象引用、可变性和垃圾回收

变量不是盒子

Python 变量类似于 Java 中的引用式变量,最好把他们理解为附加在对象上的标签。

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

如果把变量看做便利贴,就能很好地说明变量 a 和变量 b 引用用一个列表,而不是那个列表的副本:

对于应用式变量而言,说把变量分配给对象更合理,因为对象在赋值前之前已经创建。对于赋值语句而言,对象在右边创建或获取,在此之后左边的变量才会绑定到对象上,就如同为对象贴上标签:

1
2
3
4
5
6
7
8
9
>>> class SimpleObject:
... pass
...
>>> a = SimpleObject() * 10
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for *: 'SimpleObject' and 'int'
>>> dir()
['SimpleObject', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']

标识、相等性和别名

因为变量只不过是标注,所以可以为对象贴上多个标注。贴的多个标注就是别名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> lily = {'name':'lily', 'born':2000}
>>> lily_alias = lily
>>> id(lily), id(lily_alias)
(4559355456, 4559355456)
>>> lily['born'] = 2001
>>> lily_alias['born']
2001

>>> another_lily = {'name':'lily', 'born':2001}
>>> lily == another_lily
True
>>> lily is another_lily
False
>>> id(lily), id(another_lily)
(4559355456, 4559391360)
  • lily 和 lily_alias 绑定的是同一个对象,而 lily 和 another_lily 虽然内容相同,但是是两个不同的对象
  • 通过 a is b 的方式,可以判断变量所引用对象的标识是否相同
  • == 运算符比较的是两个对象的值(对象中保存的数据),而 is 比较对象的标识
  • 在判断变量和单例值(例如 None)比较时,应该使用 is

每个对象都有标识、类型和值。对象一旦创建,它的标识绝对不会变,可以把标识理解为对象在内存中的地址,is 运算符比较两个对象的标识,id() 函数返回对象标识的整数表示。

元祖和多数 Python 集合(列表、字典、集合等等)一样,保存的是对象的引用。如果引用的元素是可变的,即便元祖本身不可变,元素依然可变。元祖的不可变性其实指的是 tuple 数据结构里的物理内容(即保存的引用),与引用的对象无关。元祖的值会随着引用的可变对象的变化而变化,元祖不可变的是元素的标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> t1 = (1, 2, [30, 40])
>>> t2 = (1, 2, [30, 40])
>>> t1 == t2
True
>>> t1 is t2
False
>>> id(t1[-1])
4559353984
>>> id(t2[-1])
4557663808

>>> t1[-1].append(50)
>>> id(t1[-1])
4559353984
>>> t1 == t2
False

默认做浅复制

复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法:

1
2
3
4
5
6
>>> l1 = [3, [55, 44], (7, 8, 9)]
>>> l2 = list(l1)
>>> l1 == l2
True
>>> l2 is l1
False
  • 副本和源列表值相同,但是二者引用不同的对象
  • 对于列表和其他可变序列来说,还能使用 l2 = l1[:] 创建副本

构造方法或 [:] 做的是浅复制,即复制了最外层容器,副本中元素是源容器中元素的引用。如果所有元素都是不可变的,那么这样做没有什么问题,还能节省内存。但是如果有可变的元素,那么可能会导致意想不到的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
>>> l1.append(100)
>>> l1
[3, [55, 44], (7, 8, 9), 100]
>>> l2
[3, [55, 44], (7, 8, 9)]

>>> l1[1].remove(55)
>>> l1
[3, [44], (7, 8, 9), 100]
>>> l2
[3, [44], (7, 8, 9)]

>>> l2[1] += [33, 22]
>>> l1
[3, [44, 33, 22], (7, 8, 9), 100]
>>> l2
[3, [44, 33, 22], (7, 8, 9)]

>>> l2[2] += (10, 11)
>>> l1
[3, [44, 33, 22], (7, 8, 9), 100]
>>> l2
[3, [44, 33, 22], (7, 8, 9, 10, 11)]
>>> id(l1[2])
4559064576
>>> id(l2[2])
4558943312
  • 列表复制后,l1 l2 是两个不同的对象,但是二者引用了同一个列表和同一个元祖
  • 往 l1 中添加元素,对 l2 没有影响,因为二者是不同的列表对象
  • 往 l1[1] 中删除元素,对 l2 有影响,因为二者引用了同一个列表
  • 同样,使用就地运算符修改 l2[1],对 l1 也有影响,因为二者引用的是同一个列表
  • 但是,使用就地运算符修改 l2[2],由于其是元祖,是不可变类型,就地运算符将产生一个新的元祖来保存结果,因此 l2[2] 引用了这个新的元祖,而 l1[1] 仍然引用之前的元祖

浅复制容易操作,但是得到的结果可能并不是你想要的。有时我们需要的是深复制(即副本不共享内部对象的引用)。copy 模块提供的 deepcopy 和 copy 函数能为任意对象做深复制和浅复制。

如下展示了浅拷贝(copy)和深拷贝(deepcopy)的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3

class Bus:
def __init__(self, passengers = None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)

def pick(self, name):
self.passengers.append(name)

def drop(self, name):
self.passengers.remove(name)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> import copy
>>> import bus
>>> bus1 = bus.Bus(["A", "B", "C", "D"])
>>> bus2 = copy.copy(bus1)
>>> bus3 = copy.deepcopy(bus1)
>>> id(bus1), id(bus2), id(bus3)
(4307324736, 4308831200, 4308828368)
>>> bus1.drop("D")
>>> bus1.passengers
['A', 'B', 'C']
>>> bus2.passengers
['A', 'B', 'C']
>>> bus3.passengers
['A', 'B', 'C', 'D']
>>> id(bus1.passengers)
4308849344
>>> id(bus2.passengers)
4308849344
>>> id(bus3.passengers)
4308826688
  • 可以看到 bus1 和 bus2 共享同一个列表,因为 bus2 是 bus1 的浅拷贝
  • 而 bus3 和 bus1 引用的是不同的列表对象,因为 bus3 是 bus1 的深拷贝

深拷贝不是简单的事,如果对象有循环引用,那么朴素的算法会进入无限循环,deepcopy 函数会记住已经复制的对象,因此能够优雅地处理循环引用:

1
2
3
4
5
6
7
8
9
>>> a = [10, 20]
>>> b = [a, 30]
>>> a.append(b)
>>> a
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a)
>>> c
[10, 20, [[...], 30]]

此外深复制有时可能太深了。我们可以实现特殊方法 __copy____deepcopy__,控制 copy 和 deepcopy 的行为。

函数的参数作为引用时

Python 唯一支持的参数传递模式是共享传参(call by sharing),共享传参指各个形式参数获得实参中引用关系的副本,即形参与实参引用的是同一个对象。这样函数可以通过参数修改传入的可变对象,但是无法修改实参让其引用另一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
>>> def f(a, b):
... a += b
... return a
...
>>> x = 1
>>> y = 3
>>> f(x, y)
4
>>> x
1
>>> y
3
>>> a = [1, 2]
>>> b = [3, 4]
>>> f(a, b)
[1, 2, 3, 4]
>>> a
[1, 2, 3, 4]
>>> b
[3, 4]
>>> t = (10, 20)
>>> u = (30, 40)
>>> f(t, u)
(10, 20, 30, 40)
>>> t
(10, 20)
>>> u
(30, 40)

参数可以有默认值,这样可以在 API 进化的同时保持向后兼容,但是我们应该避免使用可变对象作为参数的默认值,如下展示了一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
>>> class TestList:
... def __init__(self, list=[]):
... self.list = list
...
>>>
>>> l1 = TestList()
>>> l2 = TestList()
>>> l1.list.append(1)
>>> l2.list
[1]
>>> l2.list
[1]
>>> l3 = TestList([])
>>> l4 = TestList([])
>>> l3.list.append(1)
>>> l3.list
[1]
>>> l4.list
[]
>>> l5 = TestList()
>>> l5.list
[1]
>>> l1.list is l2.list
True
>>> l1.list is l5.list
True
>>> l1.list is l3.list
False
>>> l1.list is l4.list
False
  • TestList 的构造函数,list 参数默认为空列表
  • 创建两个 TestList 对象,都使用默认参数进行初始化,对 l1 中的列表添加元素,l2 也受到影响
  • 创建两个 TestList 对象,这次显式传入空列表,对 l3 中的列表添加元素,对 l4 没有影响
  • 最后创建一个 TestList 对象,仍然使用默认参数进行初始化,l5 初始化完成后,list 元素并不为空
  • 最后可以看到 l1、l2、l5 的 list 是同一个对象,但是和 l3、l4 的 list 不是同一个对象

出现这个问题的根源是,默认值是在定义函数时计算的(通常在加载模块时),因此默认值变成了函数对象的属性,因此如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响:

1
2
3
4
5
6
7
8
>>> dir(TestList.__init__)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
>>> TestList.__init__.__defaults__
([1],)
>>> id(TestList.__init__.__defaults__[0])
4532205312
>>> id(l1.list)
4532205312

默认参数是可变对象时,可以使用 None 作为接收可变值参数的默认值,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> class MyList():
... def __init__(self, list = None):
... self.list = [] if list is None else list
...
>>> l1 = MyList()
>>> l1.list.append(1)
>>> l2 = MyList()
>>> l1.list
[1]
>>> l2.list
[]
>>> id(l1.list)
4358035520
>>> id(l2.list)
4358049856

该程序虽然解决了默认参数是可变对象带来的奇怪调用问题,但是如果构造时传入了列表,那么 MyList 中 list 和该实参参数引用的是同一个列表,对 MyList 的修改,将影响原有的 list 对象:

1
2
3
4
5
6
7
>>> l_arg = [1, 2, 3]
>>> l3 = Mylist(l_arg)
>>> l3.list.append(4)
>>> l3.list
[1, 2, 3, 4]
>>> l_arg
[1, 2, 3, 4]

在设计接口时,如果定义的函数接收可变参数,也应该谨慎考虑调用方是否期望修改传入的参数。例如函数如果接受一个字典,而且的过程中要修改它,那么要考虑这个修改的副作用要不要体现到函数外部。具体情况具体分析,需要函数的编写者和调用者达成共识。接口的设计应该符合 最小惊讶原则

为了解决该问题,可以在 MyList 中创建一个实参的副本,这样处理方式还更灵活,因为实参可以是任何可迭代对象了:

1
2
3
4
>>> class MyList():
... def __init__(self, list = None):
... self.list = [] if list is None else list(list)
...

del 和垃圾回收

对象绝对不会自行销毁,但是无法得到对象时,可能会被当做垃圾回收。del 语句用于删除名称,而不是删除对象。del 命令可能导致对象当做垃圾回收,但是仅当删除的变量保存的是对象的最后一个引用,或者无法得到对象时。重新绑定也可能导致对象的引用计数归零,导致对象被销毁。

在 CPython 中垃圾回收使用的主要算法是引用计数,每个对象都会统计有多少引用指向自己,当引用计数归零时,对象立即被销毁:CPython 会在对象上调用 __del__ 方法(如果定义了,该方法给实例最后的机会,释放外部资源),然后释放分配给对象的内存。Python 的其他实现有更复杂的垃圾回收程序,而且不依赖于引用计数,对象的引用计数为 0 时,可能不会立即调用 del 方法。

如下演示了对象声明结束时的情形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1
>>> def bye():
... print('Gone with wind')
...
>>> ender = weakref.finalize(s1, bye)
>>> ender.alive
True
>>> del s1
>>> ender.alive
True
>>> s2 = 'another'
Gone with wind
>>> ender.alive
False
  • 这里注销时的回调函数一定不能绑定要销毁的对象,否则永远会有一个指向该对象的引用,该对象也就永远不会被销毁。这里 fininalize 持有的是 s1 的弱引用,不会增加对象的引用计数
  • del 不删除对象,只是删除该对象上的一个引用。del s1 后 s2 仍然指向元祖对象,所以该对象不会被销毁
  • s2 重新赋值后,元祖对象无法获取,对象被销毁了

弱引用

因为有引用,对象才会在内存中存在。当对象的引用计数归零后,垃圾回收程序会把对象销毁。弱引用不会增加对象的引用数量,引用的目标对象被称为所指对象。因此弱引用不会妨碍所指对象被当做垃圾回收。弱引用在缓存应用中很有用,因为不想仅因为着缓存引用而始终保存着缓存对象。在 Python 控制台里,会自动把变量 _ 绑定到结果不为 None 的表达式上。

使用 weakref.ref 实例创建某个实例的弱引用。弱引用是可调用的对象,返回的是被引用的对象,如果所指对象不存在了,返回 None:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import weakref
>>> a_set = {0, 1}
>>> wref = weakref.ref(a_set)
>>> wref
<weakref at 0x1058989f0; to 'set' at 0x105994ac0>
>>> wref()
{0, 1}
>>> id(_)
4388899520
>>> id(a_set)
4388899520
>>> a_set = {2, 3, 4}
>>> wref()
>>> wref() is None
True
  • wref() 返回被引用的对象,由于当前在控制台会话中,所以 _ 变量也指向它
  • 之后 a_set 指向另一个集合,由于 _ 现在也指向最近的表达式上,因此 {0, 1} 这个集合没有强引用了,因此 wref() 返回 None

weakref 类其实是低层接口,供高级用途使用。多数程序最好使用 weakref 集合和 finalize,即应该使用 WeakKeyDictionary、WeakValueDictionary、WeakSet 和 finalize(在内部使用弱引用),不要自己动手创建并处理 weakref.ref 的实例。

WeakValueDictionary 类实现的是一种可变映射,里面的值是对象的弱引用。被引用的对象在程序中的其他地方被当做垃圾回收后,对应的键会自动从 WeakValueDictionary 中删除。因此 WeakValueDictionary 常用于缓存。

1
2
3
4
5
6
class Cheese:
def __init__(self, kind):
self.kind = kind

def __repr__(self):
return 'Cheese(%r)' % self.kind
1
2
3
4
5
6
7
8
9
10
11
>>> import cheese
>>> import weakref
>>> stock = weakref.WeakValueDictionary()
>>> catalog = [cheese.Cheese('A'), cheese.Cheese('B'), cheese.Cheese('C'), cheese.Cheese('D')]
>>> for c in catalog:
... stock[c.kind] = c
...
>>> sorted(stock.keys())
['A', 'B', 'C', 'D']
>>> del catalog
>>> sorted(stock.keys())
  • stock 保存了 catalog 中 Cheese 实例的弱引用
  • 当 catalog 被删除后,理论上 stock 中的 cheese 应该都消失,但是最后还存在一个 Cheese(D)。这是因为 for 循环中的变量 c 其实是全局变量(在 Python 控制台中),循环结束后,它还是指向 Cheese(D),所以引用计数不为 0 ,不被销毁

与 WeakValueDictionary 对应的是 WeakKeyDictionary,后者的键是弱引用。WeakSet 是保存元素弱引用的集合类,元素没有强引用使时,集合会把它删除。如果一个类需要知道所有实例,可以创建一个 WeakSet 类型的类属性,保存实例的引用。如果使用常规 set,实例永远不会被垃圾回收,因为类中对实例的强引用,而类存在的时间与 Python 进程一样长,除非显式删除类。

不是每个 Python 对象都可以作为弱引用的目标,这些局限基本上是 CPython 的实现细节,其他 Python 解释器中情况可能不一样。list 和 dict 实例不能作为弱引用的目标,但是它们的子类可以解决这个问题。set 实例可以作为引用目标。用户定义的类型也没有问题。int 和 tuple 的实例不能作为弱引用的目标,甚至它们的子类也不行。

对于元祖 t 来说,你可能会复现,t[:] 和 tuple(t) 都不会创建副本,它们返回的都是同一个元祖的引用:

1
2
3
4
5
6
7
8
9
10
11
12
>>> id(t)
4347625664
>>> t = (1, 2, 3)
>>> t2 = t[:]
>>> t3 = tuple(t)
>>> id(t2)
4347625664
>>> id(t3)
4347625664
>>> t4 = (1, 2, 3)
>>> t4 is t
False

str、bytes 和 frozenset 实例也有这种行为。甚至对于 str、即使新建一个同样内容的字符串,它们也都是指向同一个对象:

1
2
3
4
5
6
7
8
9
10
11
12
>>> a = 'test'
>>> b = 'test'
>>> a is b
True
>>> c = 10
>>> d = 10
>>> c is d
True
>>> e = 123456789
>>> f = 123456789
>>> e is f
False

共享字符串字面量是一种优化措施,称为驻留,CPython 还会在小的整数上使用这个优化措施,防止重复创建热门的数字。注意 CPython 不会驻留所有字符串和整数,驻留的条件是实现细节,没有文档说明。所以千万不要依赖字符串或整数的驻留。

Python 对不可变类型施加的优化能节省内存,提升解释器的速度。而且不会给你带来麻烦,因为它们都是不可变类型。如果两个变量指向的不可变对象具有相同的值,实际上它们指代的是副本还是同一个对象的别名基本没有什么关系。

符合 Python 风格的对象

得益于 Python 数据模型,自定义类型的行为可以像内置类型那样自然。实现如此自然的行为,靠的不是继承,而是鸭子类型,即只需要按照预定行为实现对象所需方法即可。

对象表示形式

Python 提供了两种方式用于获取一种对象的字符串表示形式:

  • repr():以便于开发者理解的方式返回对象的字符串表示形式
  • str():以便于用于理解的方式返回对象的字符串表示形式

我们需要自己实现 __repr____str__ 特殊方法,为 repr() str() 提供支持。另外还有两个特殊的方法:

  • __bytes__:bytes() 函数调用它获取对象的字节序列表示
  • __formats__:会被内置的 format() 函数和 str.format() 方法调用,使用特殊的格式代码显示对象的字符串表示形式

重新实现向量类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env python3

from array import array


class Vector2d:
typecode = 'd'

def __init__(self, x, y):
self.x = float(x)
self.y = float(y)

def __iter__(self):
return (i for i in (self.x, self.y))

def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)

def __str__(self):
return str(tuple(self))

def __bytes__(self):
return bytes([ord(self.typecode)]) + bytes(array(self.typecode, self))

def __eq__(self, other):
return tuple(self) == tuple(other)

def __bool__(self):
return bool(abs(self))
  • typecode 是类属性
  • __init__ 方法中把 x、y 转型为浮点数,这样能尽早捕获错误,以防止传入不当参数
  • 实现了 __iter__ 方法,把 Vector2d 对象变成可迭代对象,这样能实现拆包。这里直接通过生成器表达式不断产生分量
  • __repr__ 方法首先输出类名,然后使用 {!r} 输出各个分量的表示形式。因为 Vector2d 是一个可迭代对象,所以 *self 会把 x 和 y 分量提供给 format 函数
  • __bytes__ 方法首先输出 typecode 的字节形式,然后将该对象转化为数组,再输出数组的字节序列

如下展示了 Vector2d 的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
>>> from vector2d_v0 import Vector2d
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)
3.0 4.0
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0)
>>> octets = bytes(v1)
>>> octets
b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
>>> abs(v1)
5.0
>>> bool(v1), bool(Vector2d(0, 0))
(True, False)
  • 这里使用 eval 函数来调用 repr() 函数的返回结果,得到了正确的实例。这说明 repr() 函数的返回结果是对构造方法的准确描述。
  • print 函数调用 str() 函数,进而调用实例的 __str__ 方法。而控制台输出一个实例,是调用该实例的 __repr__ 方法
  • bytes 函数会调用实例的 __bytes__ 方法
  • abs 函数会调用实例的 __abs__ 方法
  • bool 函数会调用实例的 __bool__ 方法

为了能够从字节序列转换成 Vector2d 的实例,接下来在定义一个 frombytes 类方法:

1
2
3
4
5
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv)
  • 这里用 classmethod 装饰器表示类方法,它的第一个参数是 cls 类名参数
  • 从第一个字节中读取 typecode,通过 memoryview 的 cast 将其转换为 typecode 类型的序列
  • 通过 *memv 进行拆包,得到构造方法所需要的参数,构造对象
1
2
3
>>> v3 = vector2d_v0.Vector2d.frombytes(bytes(v1))
>>> v3 == v1
True

classmethod 和 staticmethod

classmethod 定义了操作类,而不是操作实例的方法,类方法的第一个参数是类本身(按照约定,通常命名为 cls),而不是实例。classmethod 最常见的用途是定义备选构函数。

staticmethod 装饰器也会改变方法的调用方法,但是第一个参数不是特殊的值。静态方法就是普通的函数,只是碰巧在类的定义体中,而不是在模块层定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args
... @staticmethod
... def statmeth(*args):
... return args
...
>>> Demo.klassmeth()
(<class '__main__.Demo'>,)
>>> Demo.klassmeth('test')
(<class '__main__.Demo'>, 'test')
>>> Demo.statmeth()
()
>>> Demo.statmeth('test')
('test',)
  • 无论怎么调用,classmethod 的第一个参数始终是类型
  • 而静态方法的行为与普通函数相似

classmethod 装饰器非常有用,但是 staticmethod 则不存在不得不用的情况,有时函数虽然不处理类,但是函数的功能与类紧密相关,可以把它作为类内的静态方法,但是也可以在同一模块中的类前面或者后面定义该函数。

格式化显示

内置的 format() 函数和 str.format() 方法把各个类型的格式化方式委托给相应的 .__format__(format_spec) 方法。format_spec 是格式说明符,它是:

  • format(my_obj, format_spec) 的第二个参数
  • str.format() 方法的格式化字符串,{} 里替换字段中冒号后面的部分
1
2
3
4
5
>>> f = 1 / 3
>>> format(f, '0.4f')
'0.3333'
>>> '{:0.4f}'.format(f)
'0.3333'

类似于 {0.mass:5.3e} 这样的格式字符串包含两部分:

  • 冒号左边的在替换字段语法中是字段名,它可以通过关键字或位置参数的方式指定要输出的字段
  • 冒号右边的是格式说明符,格式说明符使用的表示方法称为 格式规范微语言

格式规范微语言是可以扩展的,各个类可以自行决定如何解释 format_spec 参数。如果类没有定义 __format__ 方法,从 object 继承的方法会返回 str(my_object)

1
2
3
4
5
6
7
8
9
>>> v1 = vector2d_v0.Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
>>> '{}'.format(v1)
'(3.0, 4.0)'
>>> '{:.f}'.format(v1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported format string passed to Vector2d.__format__

此时传入格式说明符将会报错,我们需要实现自己的微语言来解决这个问题,方法如下:

1
2
3
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self)
return '{}, {}'.format(*components)
  • 通过将 fmt_spec 应用到向量的各个分量上,构成一个生成器
  • 最后将将该生成器应用到格式化字符串中,输出每一个分量格式化的字符串
1
2
3
>>> v1 = vector2d_v0.Vector2d(3, 4)
>>> format(v1, '.3f')
'3.000, 4.000'

我们也可以为类型添加自定义的格式代码,例如如下代码增加了格式代码 p 用来显示向量的极坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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]
coords = (abs(self), self.angle())
outer_fmt = '<{} {}>'
else:
coords = self
outer_fmt = '({} {})'

components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
1
2
3
4
5
6
>>> import vector2d_v0
>>> v1 = vector2d_v0.Vector2d(3, 4)
>>> format(v1, '.3f')
'(3.000 4.000)'
>>> format(v1, '.3fp')
'<5.000 0.927>'

可散列的 Vector2d

目前实现的 Vector2d 还不是可散列的,因此不能放入集合中:

1
2
3
4
>>> hash(v1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'Vector2d'

之前已经实现了 __eq__ 方法,为了把 Vector2d 实例变为可散列的,还必须使用 __hash__ 方法,另外向量必须为不可变的,为此需要把 x 和 y 设置为只读属性,为此重新实现其 __init__ 方法,并添加两个读值方法:

1
2
3
4
5
6
7
8
9
10
11
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
  • 使用两个前导下划线(尾部没有下划线,或者有一个下划线)把属性标记为私有的
  • @property 装饰器把读值方法标记为特性,读值方法与公开属性同名。需要读取 x 和 y 分量的方法可以保持不变,仍然通过 self.x 和 self.y 来读取公开属性,而不必读取私有属性

通过将对象的属性设置为只读属性,这些对象才是不可变的。接下来才能实现 __hash__ 方法。

1
2
def __hash__(self):
return hash(self.__x) ^ hash(self.__y)

现在改类型就是可散列类型:

1
2
3
4
5
6
7
>>> v1 = vector2d_v0.Vector2d(3, 4)
>>> hash(v1)
7
>>> v2 = vector2d_v0.Vector2d(5, 6)
>>> hash(v2)
3
>>> s = set([v1, v2])

如果定义的类型有标量数值,可能还需要实现 __int____float__ 方法(分别被 int() 和 float() 构造函数调用),以便在某些情况下用于强制类型转换。

要想得到功能完善的对象,这些方法可能是必备的。当然如果你的应用用不到,就没有必要全部实现这些方法。

Python 的私有属性和受保护的属性

Python 不能像 Java 那样使用 private 修饰符创建私有属性,但是 Python 有个简单的机制,能够避免子类意外覆盖私有属性。如果以 __x(两个前导下划线,尾部没有下划线或者最多有一个下划线)命名实例属性,Python 会把属性名存入实例的 dict 属性中,而且会在前面加上一个下划线和类名。这个语言特性叫做改写(name mangling):

名称改写是一种安全措施,不能保证万不一失,它的目的是避免意外访问,不能防止故意做错事。只要知道改写私有属性名的机制,任何人都能直接读取私有属性。

1
2
3
4
5
6
7
>>> v1.__dict__
{'_Vector2d__x': 3.0, '_Vector2d__y': 4.0}
>>> v1._Vector2d__x
3.0
>>> v1._Vector2d__x = 4
>>> print(v1)
(4, 4.0)

不是所有程序员都喜欢名称改写功能,也不是所有人都喜欢 self.__x 这种不对称的名称。他们约定使用一个下划线前缀编写 受保护的 属性(例如 self._x)。Python 解释器不会对使用单个下划线的属性名称做特殊处理,这只是 Python 程序员严格遵守的约定,它们不会在类外部访问这种属性。Python 文档中有时会把使用一个下划线前缀标记的属性称为 受保护的属性

使用 slots 类属性节省空间

默认情况下,Python 在各个实例中的 __dict__ 属性中存储实例属性。为了使用底层的散列表提升访问速度,字典会消耗大量内存。如果要处理包含数百万个属性的实例,通过 __slots__ 类属性,能节省大量内存,方法是让解释器在元祖中存储实例属性,而不用字典。需要注意,继承自超类的 __slots__ 属性没有效果,Python 只会使用各个类中定义的 __slots__ 属性。

定义 __slots__ 的方式,创建一个类属性,使用 __slots__ 这个名称,并且把它的值设为一个字符串构成的可迭代对象,其中各个元素表示各个实例属性。在类中定义 __slots__ 属性的目的是告诉解释器:这个类中的所有实例属性都在这儿了。这样 Python 会在各个实例中使用类似元祖的结构存储实例属性,从而避免使用消耗内存的 __dict__

在类中定义 __slots__ 属性之后,实例不能再有 __slots__ 中所列名称之外的其他属性了。这只是一个副作用,不是 __slots__ 存在的真正原因,所以不要使用 __slots__ 属性禁止类的用户新增实例属性。

如果把 __dict__ 这个名称添加到 __slots__ 中,实例会在元祖中保存各个实例的属性,此外还支持动态创建属性,这些属性存储在常规的 __dict__ 中。还有一个实例属性需要注意:__weakref__ 属性。为了让对象支持弱引用,必须有这个属性,用户定义的类中默认就有 __weakref__ 属性,可是如果类中定义了 __slots__ 属性,而且想把实例作为弱引用的目标,那么要把 __weakref__ 添加到 __slots__ 中。

覆盖类属性

Python 中有个很独特的特性,类属性可以为实例属性提供默认值,在上面的例子中,我们通过 self.typecode 的方式获取类属性 typecode,实例对象本身没有这个属性。但是如果为不存在的实例属性赋值,会新建实例属性,并为新建的实例属性赋值,同名的类属性不受影响。之后实例读取的 self.typecode 也是实例属性 typecode,即把同名的类属性给覆盖了。

如果想修改类属性的值,必须直接在类上修改,不能通过实例修改,例如如果想修改所有实例的 typecode 属性的默认值,可以通过如下方式实现:

1
Vector2d.typecode = 'f'

有种修改方法更符合 Python 风格,而且效果持久,也更有针对性,类属性是公开的,因此会被子类继承,于是经常会创建一个子类,只用于定制类的数据属性。

不管怎么样,符合 Python 风格的对象应该正好符合所需,而不是堆砌语言特性。