社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

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

VPointer • 8 年前 • 689 次点击  

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

《流畅的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'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)

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()
(<class 'Demo'>,) # 不管如何调用Demo.klassmeth,它的第一个参数始终是Demo类自己
>>> Demo.klassmeth("spam")
(<class 'Demo'>, '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'结尾,则以极坐标的形式输出向量,即<r, θ>'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'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd)
17
>>> v1.typecode = "f"
>>> dumpf = bytes(v1) # 按单精度转换
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'  # 明白为什么要在字节序列前加上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 ~


今天看啥 - 高品质阅读平台
本文地址:http://www.jintiankansha.me/t/VDMF77QS3K
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/14049