目录
前言
本章接续第一章,说明如何实现很多python类型中常见的特殊方法。包含以下话题:
- 支持用于生成对象其他表示形式的内置函数(repr()、byte()等)
- 使用一个类方法实现备选构造方法
- 扩展内置的format()函数和str.format()方法使用的格式微语言
- 实现只读属性
- 把对象变成可散列的,以便在集合中以及作为dict的键使用
- 利用__slots__节省内存
一、对象表示形式
每种面向对象语言都至少有一种获取对象的字符串表示形式的标准方式,python中有两种方式:
- repr()。便于开发者理解的方式返回对象的表示形式,即要求准确表示出构造该对象时的信息。
- str()。便于用户理解的方式返回对象的字符串表示形式,因为某些类对象是用来给用户看的,比如一个time类,返回形式是 年-月-日-小时明显比返回类名属性方法更好看。
以上两种内置函数底层会调用__repr__和__str__特殊方法来进行支持。
python对象还有两种方式返回表示形式:
- bytes(),底层是__bytes__,获取对象的字节序列表示形式。
- format()与str.format(),底层是__format__方法,使用特殊的格式代码显示对象的字符串表示形式。
二、再谈向量类
自定义二维向量Vector2d类,要求其基本行为如下:
- Vector2d实例的分量可以通过属性直接访问。
- Vector2d实例可以拆包成元组。
- 把Vector2d实例传入repr函数并调用,得到的结果类似于函数构造的源码。
- 使用eval克隆对象,传入Vector2d实例的repr函数返回结果,验证repr函数返回的是Vector2d实例的构造方法的准确描述。
- Vector2d实例支持使用 == 比较,这样便于测试。
- print函数会调用str函数,对Vector2d来说,输出一个有序对。
- bytes函数会调用__bytes__方法,生成实例的二进制表示形式(即字节序列表示形式)。
- abs函数会调用__abs__方法,返回Vector2d实例的模。
- bool函数会调用__bool__方法,如果Vector2d实例的模为零,返回False,否则返回True。
以下示例是当前定义的Vector2d类:
from array import array
import math
class Vector2d:
typecode = 'd' # 类属性,在Vector2d实例与字节序列转换时使用,使用8字节双精度浮点数表示向量的各个分量
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)) # 定义__iter__方法,把Vector2d实例变成可迭代对象,这样才能拆包,这里实现方式是直接调用生成器表达式一个一个产出分量
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)]) # 为了生成字节序列,先把typecode转换成字节序列
+ bytes(array(self.typecode, self))) # 用类型码和可迭代对象构建一个数组,然后把数组转换成字节序列
def __eq__(self, other):
return tuple(self) == tuple(other) # 先把Vector2d类转换成元组(可迭代对象都可这样转换),然后比较值的相等性,这样做会有一点问题,因为只要other是可迭代对象且只有两个元素,都可以与Vector2d实例比较,即Vector(3, 4) == [3, 4] 的结果为True。
def __abs__(self):
return math.hypot(self.x, self.y) # 求模
def __bool__(self):
return bool(abs(self)) # abs计算模,然后求布尔值
三、备选构造方法
我们已经可以把Vector2d的实例转换成字节序列了,也应该能从字节序列转换成Vector2d实例。比如array.array就有个类方法.frombytes可以实现从字节序列到对象的转换。下面为Vector2d定义一个同名的frombytes方法,来实现从字节序列到Vector2d对象。
@classmethod # 装饰器
def frombytes(cls, cotets): # 不用传入self函数,而是通过cls传入类本身,因为是类方法
typecode = chr(octets[0]) # 从第一个字节中读取typecode,chr返回当前整数(可以是任何进制)对应的ascii字符
memv = memoryview(octets[1:]).cast(typecode) # 使用传入的octets字节序列创建一个memoryview,然后使用typecode转换(此时typecode是字符 'd',相当把内存内容变成了一个浮点数类型的memoryview对象)
return cls(*memv) # 拆包memoryview对象(此时是以float的形式看待这段内存),得到构造方法所需的参数,构造出Vector2d对象
四、classmethod与staticmethod
- classmethod:定义类方法,用来操作类,其第一个参数一定是类本身,一般这个参数命名为cls(但实际上随便取个名就可以)。常见用户是定义备选构造方法。
- staticmethod:定义静态方法,静态方法就是普通的函数,只不过写在类里,由类名来调用,即只是简单地将静态方法储存在类的命名空间中。
下面示例中,类方法与静态方法的返回值都是其参数列表,可以看到,对于类方法,即使我们不传入参数,其也默认由Demo类作为第一个参数,且不管传不传参数,Demo类都是第一个参数。而静态方法则就是个由类名调用的普通函数,本书作者认为staticmethod用处不大。
五、格式化显示
内置format()函数和str.format()方法把格式化方式委托给 .__format__(format_spec)方法。format_spec是格式说明符(格式说明符使用的表示法叫格式规范微语言),其位置于:
- format(my_obj, format_spec)的第二个参数
- str.formar()方法的格式字符串中,{}里冒号后边的部分
- 格式说明符是 ‘0.4f’,保留4位小数。
- 格式说明符是{ }中 : 后边的0.2f,保留2位小数。
其中在str.format的用法中,{rate:0.2f} 与format(rate=brl)对应,以冒号和逗号分隔,冒号左边对应等号左边,冒号右边则用来格式化等号右边。冒号左边与等号左边是字段名,名字取什么无所谓但必须相等,甚至冒号左边和等号左边都可以没有,像下边这种用法也是可以的。
brl = 1 / 2.43
print(brl)
print(format(brl, '0.4f'))
print('1 BRL = {:0.2f} USD'.format(brl))
格式规范微语言为一些内置类型提供专用标识代码。b和x分别标识二进制和十六进制的int类型,f表示小数形式的float类型,而%表示百分数形式。
格式规范微语言可扩展,各个类可自行决定如何解释format_spec参数。例如,datatime模块中的类,他们的__format__方法使用的格式代码与strftime()函数一样。
from datetime import date, datetime
now = datetime.now()
print(format(now, '%H:%M:%S'))
print("It's now {:%I:%M %p}".format(now))
- %H应该是二十四小时制的当前时针数,%I代表十二小时制当前时针数
- %M是当前分针数
- %S是当前秒针数
- %p代表PM
如果自定义类没有定义__format__方法,从object继承的方法会返回str(my_object),上边Vector2d定义了__str__方法,因此使用fotmat时如下(当然,不用格式字符串):
如果传入格式字符串则会抛出TypeError。我们可以实现自己的微语言来解决这个问题。假设我们用格式说明符来格式化向量中各个浮点数分量,目的是达成以下效果:
实现方法如下:
- 因为Vector2d实现了__iter__方法,因此是可迭代的对象,这里用生成器表达式生成了一个生成器对象components,其中的元素是把fmt_spec应用到Vector2d对象迭代得到的每个分量上。
- 用生成器对象的两个元素分别代入到两个{}中去,得到最终返回形式(x, y)
这里我们的format方法并没有设置如何用格式控制符,实际上只需要通过fmt_spec格式字符串来判断就行了,暂不举例。
六、让Vector2d可散列
如果实现了一个类的__eq__方法,并且希望它是可散列的,那么它一定要有一个恰当的__hash__方法,保证在a == b为真的时候hash(a) == hash(b)也必定为真。目前的Vector2d实例是不可散列的,因此不能放入集合中。
为了让Vector2d可散列,要做两件事,一是实现__hash__方法,二是让其不可变(为了实现__hash__方法),即Vector2d的两个分量要设置成只读类型。
以下代码把Vector2d变成不可变的。
- 使用两个前导下划线(尾部没有下划线或只有一个),把属性标记为私有的(外界无法访问)。
- @property装饰器用来将一个方法变成一个相同名称的只读属性,然后就可以将方法x当成属性来访问了。
- 读值方法设置为x,因此不能直接my_obj.__x,但是可以my_obj.x来访问属性。
- 如3,返回self.__x的值。
- 同读值方法x。
以下代码实现__hash__方法,一般建议使用位异或来混合各分量的散列值:
从__hash__方法的实现也可以看出,为什么我们要让Vector2d的两个分量不可变,因为我们还要用它们计算哈希值,而对象可散列要求其哈希值不能变。
以上,Vector2d的对象就变成可散列的了。
七、python的私有属性和”受保护“属性
python中属性前加两个下划线即变成私有属性,但实际上python中并没有真正意义上的私有属性,而是用了一种叫名称改写的语言特性。即将类A的属性__x实际存储为 _A__x。
如下代码是能够正常打印的。
class A:
def __init__(self, x) -> None:
self.__x = x
a = A(5)
print(a._A__x)
虽然很多程序员约定使用单下划线的属性为私有,不要在类外部访问,但是python解释器并不会对单下划线属性进行特殊处理。也有人称单下划线属性为“受保护的”属性。
以上,我们可以看出,我们实现的Vector2d类并不能真正拥有私有的和不可变的分量。
八、使用__slots__类属性节省空间
定义类属性__slots__
默认情况下,python中实例属性__dict__字典要用存储所有的实例属性。字典的底层是散列表,散列表存储元素时由于要尽量散开,因此字典会消耗大量内存,如果实例很多(但实例的属性不多),则内存消耗巨大,通过__slots__类属性,能节省大量内存,方法是让解释器在元组(这也是为什么要求单个实例中属性不多,属性多了用元组查询就不方便了)中存储实例属性,而不用字典。
定义__slots__的方式是:创建一个名为__slots__的类属性,其值设为一个字符串构成的可迭代对象,各个元素表示各个实例属性。常使用元组,这样定义的__slots__中所含的信息不会变化。
使用__slots__的问题
- 每个子类都要定义__slots__属性,因为解释器会忽略继承的__slots__属性
- 实例只能拥有__slots__中列出的属性
- 把 ‘__dict__’加入 __slots__可以实现动态添加属性,但是不要这样做,这样会导致常规的__dict__即字典中存储实例,这就与__slots__目标背道而驰了。
- 如果不把'__weakref__'加入__slots__,实例就不能作为弱引用的目标/所指对象。
九、覆盖类属性
python独有特性:
- 类属性可用于为实例属性提供默认值。Vector2d中有个typecode类属性类属性,__bytes__方法用到的时候直接使用self.typecode读取它的值,因为实例没有这个属性,所以默认读取的是Vector2d.typecode类属性的值。
- 实例属性覆盖同名类属性,如果我们给不存在的实例属性self.typecode赋新值,则会新键一个实例属性(要动态添加自定义实例属性则不能使用__slots__优化),以后再用self.typecode读取的时候读取的是实例属性的值而非类属性。
- 如果想要修改类属性的值,必须直接在类上修改,不能通过实例修改。
- 有种修改类属性的方法更pythonic,因为类属性是公开的,因此会被子类继承,所以可以创建一个子类,专门用于定制类属性,用这个子类来实例化对象。