摘要:本篇继续学习之路,实现更多的特殊方法以让自定义类的行为跟真正的对象一样。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到和的哈希值,如果这两个值可变,那向量的哈希值就能随时变化,这将不是一个可散列的对象。
《流畅的Python》笔记。1. 前言
本篇是“面向对象惯用方法”的第二篇。前一篇讲的是内置对象的结构和行为,本篇则是自定义对象。本篇继续“Python学习之路20”,实现更多的特殊方法以让自定义类的行为跟真正的Python对象一样。
本篇要讨论的内容如下,重点放在了对象的各种输出形式上:
实现用于生成对象其他表示形式的内置函数(如repr(),bytes()等);
使用一个类方法实现备选构造方法;
扩展内置的format()函数和str.format()方法使用的格式微语言;
实现只读属性;
实现对象的可散列;
利用__slots__节省内存;
如何以及何时使用@classmethod和@staticmethd装饰器;
Python的私有属性和受保护属性的用法、约定和局限。
本篇将通过实现一个简单的二维欧几里得向量类型,来涵盖上述内容。
不过在开始之前,我们需要补充几个概念:
repr():以便于开发者理解的方式返回对象的字符串表示形式,它调用对象的__repr__特殊方法;
str():以便于用户理解的方式返回对象的字符串表示形式,它调用对象的__str__特殊方法;
bytes():获取对象的字节序列表示形式,它调用对象的__bytes__特殊方法;
format()和str.format():格式化输出对象的字符串表示形式,调用对象的__format__特殊方法。
2. 自定义向量类Vector2d我们希望这个类具备如下行为:
# 代码1 >>> v1 = Vector2d(3, 4) >>> print(v1.x, v1.y) # Vector2d实例的分量可直接通过实例属性访问,无需调用读值方法 3.0 4.0 >>> x, y = v1 # 实例可拆包成变量元组 >>> x, y (3.0, 4.0) >>> v1 # 我们希望__repr__返回的结果类似于构造实例的源码 Vector2d(3.0, 4.0) >>> v1_clone = eval(repr(v1)) # 只是为了说明repr()返回的结果能用来生成实例 >>> v1 == v1_clone # Vector2d需支持 == 运算符 True >>> print(v1) # 我们希望__str__方法以如下形式返回实例的字符串表示 (3.0, 4.0) >>> octets = bytes(v1) # 能够生成字节序列 >>> octets b"dx00x00x00x00x00x00x08@x00x00x00x00x00x00x10@" >>> abs(v1) # 能够求模 5.0 >>> bool(v1), bool(Vector2d(0, 0)) # 能进行布尔运算 (True, False)
Vector2d的初始版本如下:
# 代码2 from array import array import math class Vector2d: # 类属性,在Vector2d实例和字节序列之间转换时使用 typecode = "d" # 转换成C语言中的double类型 def __init__(self, x, y): self.x = float(x) # 构造是就转换成浮点数,尽早在构造阶段就捕获错误 self.y = float(y) def __iter__(self): # 将Vector2d实例变为可迭代对象 return (i for i in (self.x, self.y)) # 这是生成器表达式! def __repr__(self): class_name = type(self).__name__ # 获取类名,没有采用硬编码 # 由于Vector2d实例是可迭代对象,所以*self会把x和y提供给format函数 return "{}({!r}, {!r})".format(class_name, *self) def __str__(self): return str(tuple(self)) # 由可迭代对象构造元组 def __bytes__(self): # ord()返回字符的Unicode码位;array中的数组的元素是double类型 return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self))) def __eq__(self, other): # 这样实现有缺陷,Vector(3, 4) == [3, 4]也会返回True return tuple(self) == tuple(other) # 但这个缺陷会在后面章节修复 def __abs__(self): # 计算平方和的非负数根 return math.hypot(self.x, self.y) def __bool__(self): # 用到了上面的__abs__来计算模,如果模为0,则是False,否则为True return bool(abs(self))3. 备选构造方法
初版Vector2d可将它的实例转换成字节序列,但却不能从字节序列构造Vector2d实例,下面添加一个方法实现此功能:
# 代码3 class Vector2d: -- snip -- @classmethod def frombytes(cls, octets): # 不用传入self参数,但要通过cls传入类本身 typecode = chr(octets[0]) # 从第一个字节中读取typecode,chr()将Unicode码位转换成字符 # 使用传入的octets字节序列构建一个memoryview,然后根据typecode转换成所需要的数据类型 memv = memoryview(octets[1:]).cast(typecode) return cls(*memv) # 拆包转换后的memoryview,然后构造一个Vector2d实例,并返回4. classmethod与staticmethod
代码3中用到了@classmethod装饰器,与它相伴的还有@staticmethod装饰器。
从上述代码可以看出,classmethod定义的是传入类而不是传入实例的方法,即传入的第一个参数必须是类,而不是实例。classmethod改变了调用方法的方式,但是,在实际调用这个方法时,我们不需要手动传入cls这个参数,Python会自动传入。(按照传统,第一个参数一般命名为cls,当然你也可以另起名)
staticmethod也会改变方法的调用方式,但第一个参数不是特殊值,既不是cls,也不是self,就是用户传入的普通参数。以下是它们的用法对比:
# 代码4 >>> class Demo: ... @classmethod ... def klassmeth(*args): ... return args # 返回传入的全部参数 ... @staticmethod ... def statmeth(*args): ... return args # 返回传入的全部参数 ... >>> Demo.klassmeth() (,) # 不管如何调用Demo.klassmeth,它的第一个参数始终是Demo类自己 >>> Demo.klassmeth("spam") ( , "spam") >>> Demo.statmeth() () # Demo.statmeth的行为与普通函数类似 >>> Demo.statmeth("spam") ("spam",)
classmethod很有用,但staticmethod一般都能找到很方便的替代方案,所以staticmethod并不是必须的。
5. 格式化显示内置的format()函数和str.format()方法把各个类型的格式化方式委托给相应的.__format__(format_spec)方法。format_spec是格式说明符,它是:
format(my_obj, format_spec)的第二个参数;
也是str.format()方法的格式字符串,{}里替换字段中冒号后面的部分,例如:
# 代码5 >>> brl = 1 / 2.43 >>> "1 BRL = {rate:0.2f} USD".format(rate=brl) # 此时 format_spec为"0.2f"
其中,冒号后面的0.2f是格式说明符,冒号前面的rate是字段名称,与格式说明符无关。格式说明符使用的表示法叫格式规范微语言(Format Specification Mini-Language)。格式规范微语言为一些内置类型提供了专门的表示代码,比如b表示二进制的int类型;同时它还是可扩展的,各个类可以自行决定如何解释format_spec参数,比如时间的转换格式%H:%M:%S,就可用于datetime类型,但用于int类型则可能报错。
如果类没有定义__format__方法,则会返回__str__的结果,比如我们定义的Vector2d类型就没有定义__format__方法,但依然可以调用format()函数:
# 代码6 >>> v1 = Vector2d(3, 4) >>> format(v1) "(3.0, 4.0)"
但现在的Vector2d在格式化显示上还有缺陷,不能向format()传入格式说明符:
>>> format(v1, ".3f") Traceback (most recent call last): -- snip -- TypeError: non-empty format string passed to object.__format__
现在我们来为它定义__format__方法。添加自定义的格式代码,如果格式说明符以"p"结尾,则以极坐标的形式输出向量,即
# 代码7 class Vector2d: -- snip -- def angle(self): return math.atan2(self.y, self.x) # 弧度 def __format__(self, format_spec=""): if format_spec.endswith("p"): format_spec = format_spec[:-1] coords = (abs(self), self.angle()) outer_fmt = "<{}, {}>" else: coords = self outer_fmt = "({}, {})" components = (format(c, format_spec) for c in coords) return outer_fmt.format(*components)
以下是实际示例:
# 代码8 >>> format(Vector2d(1, 1), "0.5fp") "<1.41421, 0.78540>" >>> format(Vector2d(1, 1), "0.5f") "(1.00000, 1.00000)"6. 可散列的Vector2d
关于可散列的概念可以参考之前的文章《Python学习之路22》。
目前的Vector2d是不可散列的,为此我们需要实现__hash__特殊方法,而在此之前,我们还要让向量不可变,即self.x和self.y的值不能被修改。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到self.x和self.y的哈希值,如果这两个值可变,那向量的哈希值就能随时变化,这将不是一个可散列的对象。
补充:
在文章《Python学习之路22》中说道,用户自定义的对象默认是可散列的,它的散列值等于id()的返回值。但是此处的Vector2d却是不可散列的,这是为什么?其实,如果我们要让自定义类变为可散列的,正确的做法是同时实现__hash__和__eq__这两个特殊方法。当这两个方法都没有重写时,自定义类的哈希值就是id()的返回值,此时自定义类可散列;当我们只重写了__hash__方法时,自定义类也是可散列的,哈希值就是__hash__的返回值;但是,如果只重写了__eq__方法,而没有重写__hash__方法,此时自定义类便不可散列。
这里再次给出可散列对象必须满足的三个条件:
支持hash()函数,并且通过__hash__方法所得到的哈希值是不变的;
支持通过__eq__方法来检测相等性;
若a == b为真,则hash(a) == hash(b)也必须为真。
根据官方文档,最好使用异或运算^混合各分量的哈希值,下面是Vector2d的改进:
# 代码9 class Vector2d: -- snip -- def __init__(self, x, y): self.__x = float(x) self.__y = float(y) @property # 把方法变为属性调用,相当于getter方法 def x(self): return self.__x @property def y(self): return self.__y def __hash__(self): return hash(self.x) ^ hash(self.y) -- snip --
文章至此说的都是一些特殊方法,如果想到得到功能完善的对象,这些方法可能是必备的,但如果你的应用用不到这些东西,则完全没有必要去实现这些方法,客户并不关心你的对象是否符合Python风格。
Vector2d暂时告一段落,现在来说一说其它比较杂的内容。
7. Python的私有属性和"受保护的"属性Python不像C++、Java那样可以用private关键字来创建私有属性,但在Python中,可以以双下划线开头来命名属性以实现"私有"属性,但是这种属性会发生名称改写(name mangling):Python会在这样的属性前面加上一个下划线和类名,然后再存入实例的__dict__属性中,以最新的Vector2d为例:
# 代码10 >>> v1 = Vector2d(1, 2) >>> v1.__dict__ {"_Vector2d__x": 1.0, "_Vector2d__y": 2.0}
当属性以双下划线开头时,其实是告诉别的程序员,不要直接访问这个属性,它是私有的。名称改写的目的是避免意外访问,而不能防止故意访问。只要你知道规则,这些属性一样可以访问。
还有以单下划线开头的属性,这种属性在Python的官方文档的某个角落里被称为了"受保护的"属性,但Python不会对这种属性做特殊处理,这只是一种约定俗成的规矩,告诉别的程序员不要试图从外部访问这些属性。这种命名方式很常见,但其实很少有人把这种属性叫做"受保护的"属性。
还是那句话,Python中所有的属性都是公有的,Python没有不能访问的属性!这些规则并不能阻止你有意访问这些属性,一切都看你遵不遵守上面这些"不成文"的规则了。
8. 覆盖类属性这里首先需要区分两个概念,类属性与实例属性:
类属性属于整个类,该类的所有实例都能访问这个属性,可以动态绑定类属性,动态绑定的类属性所有实例也都可以访问,即类属性的作用域是整个类。可以按Vector2d中定义typecode的方式来定义类属性,即直接在class中定义属性,而不是在__init__中;
实例属性只属于某个实例对象,实例也能动态绑定属性。实例属性只能这个实例自己访问,即实例属性的作用域是类对象作用域。实例属性需要和self绑定,self指向的是实例,而不是类。
Python有个很独特的特性:类属性可用于为实例属性提供默认值。
Vector2d中有个typecode类属性,注意到,我们在__bytes__方法中通过self.typecode两次用到了它,这里明明是通过self调用实例属性,可Vector2d的实例并没有这个属性。self.typecode其实获取的是Vector2d.typecode类属性的值,而至于怎么从实例属性跳到类属性的,以后有机会多带带用一篇文章来讲。
补充:证明实例没有typecode属性
# 代码11 >>> v = Vector2d(1, 2) >>> v.__dict__ {"_Vector2d__x": 1.0, "_Vector2d__y": 2.0} # 实例中并没有typecode属性
如果为不存在的实例属性赋值,则会新建该实例属性。假如我们为typecode实例属性赋值,同名类属性不会受到影响,但会被实例属性给覆盖掉(类似于之前在函数闭包中讲的局部变量和全局变量的区别)。借助这一特性,可以为各个实例的typecode属性定制不同的值,比如在生成字节序列时,将实例转换成4字节的单精度浮点数:
# 代码12 >>> v1 = Vector2d(1.1, 2.2) >>> dumpd = bytes(v1) # 按双精度转换 >>> dumpd b"dx9ax99x99x99x99x99xf1?x9ax99x99x99x99x99x01@" >>> len(dumpd) 17 >>> v1.typecode = "f" >>> dumpf = bytes(v1) # 按单精度转换 >>> dumpf b"fxcdxccx8c?xcdxccx0c@" # 明白为什么要在字节序列前加上typecode的值了吗?为了支持不同格式。 >>> len(dumpf) 9 >>> Vector2d.typecode "d"
如果想要修改类属性的值,必须直接在类上修改,不能通过实例修改。如果想修改所有实例的typecode属性的默认值,可以这么做:
# 代码13 Vector2d.typecode = "f"
然而有种方式更符合Python风格,而且效果持久,也更有针对性。通过继承的方式修改类属性,生成专门的子类。Django基于类的视图就大量使用了这个技术:
# 代码14 >>> class ShortVector2d(Vector2d): ... typecode = "f" # 只修改这一处 ... >>> sv = ShortVector2d(1/11, 1/27) >>> sv ShortVector2d(0.09090909090909091, 0.037037037037037035) # 没有硬编码class_name的原因 >>> len(bytes(sv)) 99. __slots__类属性
默认情况下,Python在各个实例的__dict__属性中以映射类型存储实例属性。正如《Python学习之路22》中所述,为了使用底层的散列表提升访问速度,字典会消耗大量内存。如果要处理数百万个属性不多的实例,其实可以通过__slots__类属性来节省大量内存。做法是让解释器用类似元组的结构存储实例属性,而不是字典。
具体用法是,在类中创建这个__slots__类属性,并把它的值设为一个可迭代对象,其中的元素是其余实例属性的字符串表示。比如我们将之前定义的Vector2d改为__slots__版本:
# 代码15 class Vector2d: __slots__ = ("__x", "__y") typecode = "d" # 其余保持不变 -- snip --
试验表明,创建一千万个之前版本的Vector2d实例,内存用量高达1.5GB,而__slots__版本的Vector2d的内存用量不到700MB,并且速度也比之前的版本快。
但__slots__也有一些需要注意的点:
使用__slots__之后,实例不能再有__slots__中所列名称之外的属性,即,不能动态添加属性;如果要使其能动态添加属性,必须在其中加入"__dict__",但这么做又违背了初衷;
每个子类都要定义__slots__属性,解释器会忽略掉父类的__slots__属性;
自定义类中默认有__weakref__属性,但如果定义了__slots__属性,而且还要自定义类支持弱引用,则需要把"__weakref__"加入到__slots__中。
总之,不要滥用__slots__属性,也不要用它来限制用户动态添加属性(除非有意为之)。__slots__在处理列表数据时最有用,例如模式固定的数据库记录,以及特大型数据集。然而,当遇到这类数据时,更推荐使用Numpy和Pandas等第三方库。
10. 总结本篇首先按照一定的要求,定义了一个Vector2d类,重点是如果实现这个类的不同输出形式;随后,能从字节序列"反编译"成我们需要的类,我们实现了一个备选构造方法,顺带介绍了@classmethod和@staticmethod装饰器;接着,我们通过重写__format_方法,实现了自定义格式化输出数据;然后,通过使用@property装饰器,定义"私有"属性以及重写__hash__方法等操作实现了这个类的可散列化。至此,关于Vector2d的内容基本结束。最后,我们介绍了两种常见类型的属性(“私有”,“保护”),覆盖类属性以及如何通过__slots__节省内存等问题。
本文实现了这么多特殊方法只是为展示如何编写标准Python对象的API,如果你的应用用不到这些内容,大可不必为了满足Python风格而给自己增加负担。毕竟,简洁胜于复杂。
迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/41863.html
摘要:函数内省的内容到此结束。函数式编程并不是一个函数式编程语言,但通过和等包的支持,也可以写出函数式风格的代码。 《流畅的Python》笔记。本篇主要讲述Python中函数的进阶内容。包括函数和对象的关系,函数内省,Python中的函数式编程。 1. 前言 本片首先介绍函数和对象的关系;随后介绍函数和可调用对象的关系,以及函数内省。函数内省这部分会涉及很多与IDE和框架相关的东西,如果平时...
摘要:前言数据模型其实是对框架的描述,它规范了这门语言自身构件模块的接口,这些模块包括但不限于序列迭代器函数类和上下文管理器。上述类实现了方法,它可用于需要布尔值的上下文中等。但多亏了它是特殊方法,我们也可以把用于自定义数据类型。 《流畅的Python》笔记。本篇是Python进阶篇的开始。本篇主要是对Python特殊方法的概述。 1. 前言 数据模型其实是对Python框架的描述,它规范了...
阅读 1659·2023-04-25 22:42
阅读 2175·2021-09-22 15:16
阅读 3470·2021-08-30 09:44
阅读 467·2019-08-29 16:44
阅读 3286·2019-08-29 16:20
阅读 2492·2019-08-29 16:12
阅读 3369·2019-08-29 16:07
阅读 644·2019-08-29 15:08