Python 学习笔记——5. 类
主要是关于用户自定义类的知识,比较重要,由于 Python 的语法太自由了,这部分写起来反而很麻烦,需要想方设法加上限制。
特点
Python3 中所有的类都会默认继承一个基类 object,因此会具有一些基础的属性/方法。(在 Python2 中,自定义类是否继承 object 会有一些细微的区别)
在 Python 中,一切都是对象,有函数对象,有类对象,还有类得到的实例对象,因此和 C++不同,下文不会使用对象这个词来代表实例,我们需要区分类对象和它产生的实例对象。
Python 类模型的语法太过于自由了,写起来就像搭积木一样自由,并且类和实例对象都支持支持动态修改,写法充满危险。(注:基本数据类型比如 int 不支持动态修改它的属性,只有自定义类型可以)
Python 作为典型的动态类型语言,在使用过程中并不存在对函数参数类型的强制约束,传入的对象只要满足相应的接口要求,就可以完全正常使用。这种处理方式通常被称为鸭子模型:当一个东西走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么它就可以被称为鸭子。这种放弃类型检查的做法使得代码的编写更加简单,但是也遗留了更多的隐患。
Python vs C++:
- Python 的语法非常自由,甚至没有提供常量等概念来保证安全,因此程序的健壮性完全依赖程序员自身。下文的某些用法称为习惯上的,代表 Python 开发中的推荐用法,不遵循这些习惯并不会被解释器报错,但是可能出现难以预料的麻烦。
- C++的语法非常繁琐,但也非常丰富,即使 C++不断地引入各种约束和补丁来提高安全性,但是 C++可以直接接触底层的特点,以及高效率的要求仍然让代码具有危险性,程序员可能通过各种技巧破坏程序的健壮性。
简单例子
最简单的空类
1 | class Demo: |
带有类的数据属性和普通方法属性的类
1 | class Demo: |
带有构造方法的类,它构造的每一个实例都自动具有一个数据属性 x
1 | class Demo: |
可以使用 dir(ClassName) 查看这个类的所有属性,包括用户定义方法和自带的特殊方法,例如
1 | class Demo: |
或者查看 __dict__ 这个特殊属性,会返回一个字典
1 | class Demo: |
类对象
在定义并执行类的定义之后,Python 就会创建一个类对象,这个对象主要有两种操作:
- 类的属性的访问和修改(包括属性的添加,删除等)
- 实例化得到实例对象
类的属性
定义在类当中的变量即类的属性,可以通过类名直接访问和修改,通常包括数据属性和方法属性,类的方法属性就是定义在类中的函数,这里对于方法的介绍非常简略,详细内容见下文的实例调用类的方法属性,以及后面的装饰器部分
例如
1 | class Demo: |
注意:在类的其它方法内部访问类属性时,要加上类名前缀,否则显示变量未定义,类属性相互之间仍然是不可见的。
除了自定义的类属性,还有形如 __XXX__ 的特殊属性,有相应的特殊含义,例如 __init__ 即构造方法。
类的方法并不要求定义在类的内部,也可以指向一个已经定义的函数
1 | def f(): |
我们甚至可以在类的定义完成之后,动态地添加或修改类的属性
1 | class Demo: |
这是一个很灵活的机制,就像 Python 不需要提前声明变量一样,在 Python 中一个自定义的对象(类对象和下面的实例对象)的属性或者说成员并不是在定义时就固定的,我们可以实时动态修改,给它添加任意的数据或功能。
实例化
在默认情形下,类对象可以用如下语句得到实例对象
1 | class Demo: |
这是最简单的类定义,pass也可以换成...占位,因为不允许类的定义部分为空(就像空函数体一样)
如果我们在类的定义中,包含了 __init__ 构造方法,那么实例化在最后会调用这个构造方法,我们可以给构造方法提供更多的参数,甚至是不定参数,例如
1 | class Demo: |
如果不显式提供 __init__ 方法,相当于采用了默认的只有 self 参数的构造方法。通常不支持多个 __init__ 方法,可以使用不定参数 *args,**kwargs 来间接实现。
实例对象
实例的属性
通过类对象的实例化,可以得到实例对象,实例对象和类对象有一个共同点——都是自定义对象,因此都可以动态地修改自己的属性。
例如:
1 | class Demo: |
通常习惯上在 __init__ 构造方法中,基于构造参数,添加所有的实例属性,这样可以确保所有的实例在构造之后,都自动具有相应的属性。
1 | class Demo: |
如果上文不使用 self.x,而是仅仅写作 x,则会视作方法内部的局部变量,不会视作实例的属性,如果与类属性同名,也不会视作类属性。(C++隐含调用 this 指针,但是 Python 不会)
实例调用类的属性
实例对象和产生它的类对象是具有紧密关系的,这个联系体现在:**在读取实例对象的属性时,如果没有在自身找到,那么会向上寻找类对象的同名属性,如果这个属性还是可调用的方法,那么会将自身作为第一个参数传递给方法属性。**因此在类的方法属性定义中,习惯上总是在第一个参数使用 self,无论是否需要。
注意:
- 只有找到的方法属性是类的,实例才会把自己作为第一个参数传递,但是如果这个方法属性是实例自身的,并不会把自己作为第一个参数传递。
- 在修改实例对象的属性时,如果没有在自身找到,就会立刻创建该属性,视作属性的添加,即使有类对象的同名属性,也与之没有关联。见下文
- 在类的方法内部调用其他方法时,仍然要通过
self.xxx()形式调用
验证上面的一段话,首先我们不给实例对象添加任何属性,此时对于数据属性,作如下实验:
1 | class Demo: |
可以发现,通过实例访问类的数据属性,每次都会动态查找并返回当前类属性的值,但是并不会将它保存下来作为实例的属性。或者查看 __dict__ 属性也可以知道:
1 | class Demo: |
对于方法属性,同样的实验
1 | class Demo: |
可以发现,通过实例访问类的方法属性,每次都会动态查找并把自身作为第一个参数传入,并不会将它保存下来作为实例属性。
但是我们继续实验,发现实例访问自己的可调用属性,并不会自动把自己作为第一个参数。
1 | a.f = lambda self:print("hi-2",self.name) |
对于类的方法属性,通过类名和实例名都可以调用,但是尤其注意的时:通过实例调用时会自动将实例自身作为第一个参数(通常为self)传入方法中。可以通过装饰器来改变这种默认行为,此时就可以把类的方法属性分成三类:
- 普通方法(实例方法)(默认的方法,不使用任何装饰器),通过实例名调用时,会将实例对象作为第一个参数(通过为
self)传入方法中 - 类方法(基于
@classmethod装饰器),无论通过类名还是实例名调用,都会将类对象作为第一个参数(通常为cls)传入方法中 - 静态方法(基于
@staticmethod装饰器),无论通过类名还是实例名调用,不会进行任何的自动传参
例如
1 | class Demo: |
实例调用自身属性
如果实例自己显式添加或修改了某些属性,并且与类的属性重名,那么访问这些属性就会直接访问实例属性,而不是类的属性,此时并不会影响类的属性,但是不再可以通过实例名调用了,相当于全局变量被局部变量遮蔽了。
例如
1 | class Demo: |
上面对实例属性的修改并没有影响到类属性。当然由于 Python 变量的底层原理,如果实例属性作为类属性的赋值,还是可能将修改影响到类属性的,例如
1 | class Demo: |
Python 允许我们限制一个类的实例可以存在哪些属性,通过设置__slots__属性实现,例如
1 | class Demo: |
此时实例的属性名称被固定,不再具有__dict__属性
1 | class A: |
这里固定的含义为:不能新增未声明的属性,但是已有属性仍然可以修改。
显式指定 __slots__ 的做法可以大幅减少内存占用,适合需要生成大量实例的情景。
Monkey Patching(猴子补丁)
前两节已经提到,Python 的类对象和实例对象都支持在运行时动态修改属性,这里再进行一个整理。
我们把在运行时修改已有类或模块的行为称为 Monkey Patching(猴子补丁)。
这种机制依赖于 Python 的两个特点:
- 一切都是对象(函数、类、模块都是对象)
- 对象的属性可以在运行时修改
例如我们可以在类定义完成之后,替换它的方法:
1 | class Demo: |
此时所有实例调用 hello 方法时都会执行新的实现。
可以为类动态添加方法
1 | class Demo: |
也可以直接修改模块中的函数:(非常危险)
1 | import math |
这种灵活性使得 Monkey Patching 在某些场景中是有用的,例如:
- 测试(mock)
- 修复第三方库的 bug
- 运行时扩展功能
例如在测试中替换网络请求函数:
1 | import requests |
这样程序调用 requests.get 时不会真正发起网络请求。
Monkey Patching 具有较高的灵活性同时也意味着风险:
- 修改行为可能影响整个程序
- 代码可读性下降
- 调试困难
- 不同模块之间可能产生隐式依赖
因此通常只在以下情况使用:
- 测试(mock)
- 临时修复第三方库问题
- 特殊框架机制(例如 gevent)
方法的延迟调用
如果把实例调用的方法记录下来,进行延迟调用,在实际调用之前,实例对象和类对象都发生了修改,那么会发生什么?
1 | class Demo: |
上文中的 f 保留了实例的引用,在最后调用时,实例对象的修改被体现出来了,但是类对象的修改并没有体现出来,说明 f 已经锁定了 Demo.do 当时指向的函数对象,并不会随着 Demo.do=Demo.do2 的修改而改变。
私有属性
Python 其实是没有访问权限的概念,就像没有常量概念一样,但是我们在类模型中确实需要私有的属性,习惯上的做法是使用两个下划线开头的标识符,例如 __AAA 或者 __AAA_。注意不要使用首尾双下划线的 __AAA__,这代表特殊方法。
Python 解释器会自动修饰这些标识符,实际就是在前面加上 _ClassName 前缀,避免误用。
私有数据属性例如
1 | class Demo: |
这里在内部仍然可以用 __value 名称访问,但是在外部看来,实际名称为 _Demo__value,可以通过修饰后的名称调用
1 | print(a._Demo__value) # 1 |
私有方法属性也是同样的处理,例如
1 | class Demo: |
在外部可以通过修饰后的名称调用
1 | a._Demo__do_something() |
对于两个下划线开头的 __XXX,Python 会强制用类名进行名称修饰,主要的考量还是在类的继承中可以遇到的麻烦。
还有一些比较弱的私有概念,仅仅使用一个下划线开头的 _XXX,这时也是不希望从类的外部(或者模块外部)访问的,但这只是一种约定。
类的继承
Python 中所有的类都继承自 object 这个基类,它提供了通用的一些基础属性。
对于自定义类,继承的语法如下
1 | class Base: |
继承类会记住它的基类,因为在查找继承类的某个属性时,如果找不到就会递归地向上查找。Python 也支持多继承,但是同样会出现菱形继承等问题,不推荐使用。
1 | class Derived(Base1,Base2): |
可以使用两个函数来动态检查继承关系:
isinstance(Instace,Class)返回 True,当 Instance 是 Class 或者它的子类产生的实例时;issubclass(Class1,Class2)返回 True,当 Class1 是 Class2 的子类时。
调用基类方法
如果继承类没有重写基类的方法,那么可以直接获取基类的方法属性并调用,如果重写了基类的方法,则存在调用的歧义。
继承类是有必要调用基类的方法的,尤其是在经常重写的构造方法中,有必要先调用基类的构造方法。解决办法有两种,第一种是使用基类的类名调用,例如
1 | class Base: |
第二种选择是直接使用内置函数 super(),它可以自动获取当前的基类,然后调用基类的方法,例如
1 | class Derived(Base): |
方法重写
继承类可以重写基类的方法,Python 的实例方法调用相当于全都是 C++的 虚函数+引用->动态绑定,执行哪个方法只取决于当前对象 self 到底是基类还是继承类,例如
1 | class Base: |
这里虽然传递给基类的 __init__,但是在基类构造方法中,self.play() 调用的还是继承类的对应方法。
如果使用的是类方法调用(在第一个参数处传入实例对象)而不是实例方法调用,那么就不存在这种问题,会严格按照类名去找对应的方法。
1 | class Base: |
结构体
在 Python 中我们有时希望使用类似 C 语言的结构体功能,将一些量简单打包,此时可以借助空类和实例的动态添加属性来实现,例如
1 | class Empty: |
这里的动态修改属性,对于自定义类总是可以的,但是对于基础的类如 int 是不可以的。
运算符重载
在 Python 中对于自定义类的运算符操作,以及 print 输出,本质上会调用相应的特殊方法,因此我们可以通过实现对应特殊方法,来完成运算符重载。
自定义类支持 print 输出,可以通过重写 __str__ 方法,生成输出的字符串来完成。
1 | class Point: |
与之类似的还有 __repr__ 方法,jupyter notebook 对于每一个cell的最后一个语句,可能会尝试自动调用 __repr__ 或者类似方法获取信息并输出,这个方法得到的结果与 __str__ 略有区别,因为两者定位不同:
__repr__是给开发者(debug)用的,追求准确性,通常包括更详细的信息;__str__是给用户(展示)用的,追求可读性。
如果自定义类型没有提供 __str__ 方法,print 也会尝试调用 __repr__ 方法获取信息。
自定义类型支持加法,可以通过重写 __add__ 方法来完成。
1 | class Point: |
注意 Python 并不能对类型进行严格限制,因此这里实现的 __add__ 操作对于任何具有 x,y 属性的对象都是可以的,例如使用具名元组创建的对象也可以与其进行相加
1 | from typing import NamedTuple |
虽然加法没有方向性,但是作为方法调用却是存在的,例如这里反过来进行相加就是错误的
1 | print(Point2(x=3, y=4) + p1) # error |
原因也很简单,Point2 类型没有提供这种方法,我们可以在 Point2 类型添加 __add__ 方法,也可以给 Point 类型添加反向的 __radd__ 方法。
1 | from typing import NamedTuple |
常见的数值的算法运算对应的特殊方法例如:(注意带 r 的反向二元运算符的参数顺序)
__neg__:-a负号__add__/__radd__:a + b__sub__/__rsub__:a - b__mul__/__rmul__:a * b__truediv__/__rtruediv__:a / b__pow__:a ** b__iadd__:a += b__imul__:a *= b
其中:
- 名称
__truediv__涉及到 Python2 到 Python3 的除法改变 __ixx__代表就地运算__rxx__通常是为了保证加法乘法等的交换性
自定义类型支持等于号判断,可以通过重写 __eq__ 方法来完成。(大于小于等也有对应的一系列特殊方法)
1 | class Point: |
一个最常见的自定义重载是模仿函数调用,通过重写 __call__ 方法实现。
1 | class Add: |
其它运算符同理,略。
