资讯专栏INFORMATION COLUMN

Python学习之路28-符合Python风格的对象

Eric / 2740人阅读

摘要:本篇继续学习之路,实现更多的特殊方法以让自定义类的行为跟真正的对象一样。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到和的哈希值,如果这两个值可变,那向量的哈希值就能随时变化,这将不是一个可散列的对象。

《流畅的Python》笔记。
本篇是“面向对象惯用方法”的第二篇。前一篇讲的是内置对象的结构和行为,本篇则是自定义对象。本篇继续“Python学习之路20”,实现更多的特殊方法以让自定义类的行为跟真正的Python对象一样。
1. 前言

本篇要讨论的内容如下,重点放在了对象的各种输出形式上:

实现用于生成对象其他表示形式的内置函数(如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"结尾,则以极坐标的形式输出向量,即"p"之前的部分做正常处理;如果没有"p",则按笛卡尔坐标形式输出。为此,我们还需要一个计算弧度的方法angle

# 代码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.xself.y的值不能被修改。之所以要让向量不可变,是因为我们在计算向量的哈希值时需要用到self.xself.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))
9
9. __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学习之路24-一等函数

    摘要:函数内省的内容到此结束。函数式编程并不是一个函数式编程语言,但通过和等包的支持,也可以写出函数式风格的代码。 《流畅的Python》笔记。本篇主要讲述Python中函数的进阶内容。包括函数和对象的关系,函数内省,Python中的函数式编程。 1. 前言 本片首先介绍函数和对象的关系;随后介绍函数和可调用对象的关系,以及函数内省。函数内省这部分会涉及很多与IDE和框架相关的东西,如果平时...

    wind3110991 评论0 收藏0
  • Python学习之路20-数据模型

    摘要:前言数据模型其实是对框架的描述,它规范了这门语言自身构件模块的接口,这些模块包括但不限于序列迭代器函数类和上下文管理器。上述类实现了方法,它可用于需要布尔值的上下文中等。但多亏了它是特殊方法,我们也可以把用于自定义数据类型。 《流畅的Python》笔记。本篇是Python进阶篇的开始。本篇主要是对Python特殊方法的概述。 1. 前言 数据模型其实是对Python框架的描述,它规范了...

    ad6623 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<